From 41367fdb95c37358790e823afff8144de1c5d721 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 19:11:49 -0400 Subject: [PATCH 01/21] fix(accuracy): re-sync AccuracyCoin to f5f41dc2; fix two defects it found T-ACCURACYCOIN-RESYNC-2610 (v3.1.0 item 1, D25). The battery moves from upstream 46199ae4 to f5f41dc2: nine commits, two new tests (DMA Landing on Write $0496, DMC Reload Timing $0497), and new row macros. The catalog grows 149 -> 151 rows / 144 -> 146 scored, and the battery reads 146 of 146. THE NEW ROM FOUND TWO DEFECTS, FIXED RED FIRST 1. A DMC load DMA refused by a write took three cycles, not four (DMA Landing on Write, error 9 = test 9). RDY cannot halt a write. When a pending load reaches the get half it would enter on and that cycle is a CPU write, it is refused and enters on the NEXT read whichever half that is: four cycles ([Put (halt)] [Get] [Put] [Get]) after one refusing write, three after two. Our "a load enters on its get half" deferral applied a second time and pushed it to cycle 2, so the CPU ran the opcode fetch the hardware spends halted and was one cycle ahead from then on. Found by a black-box per-cycle comparison with the TriCNES harness's output, aligned on the test's STA $5000 (rung 3; no TriCNES source was read). Both sides show the load pending on the write cycle (a get half); TriCNES halts at +1, we fetched the opcode there. Fix: a one-shot latch, `dmc_load_write_delayed` (bus.rs), set in Bus::write when a pending, serviceable load would have entered there, consumed by the DMC entry in unified_dma_cycle_impl (which then skips the get-half defer), cleared by the next CPU read. It outlives an instruction (the refusing write is a store's last cycle), so it is in the BUS save-state section: BUS_SECTION_VERSION 2 -> 3, older sections refused. 2. Misaligned sprite evaluation (Misaligned OAM behavior), three rules, each red in turn (error 3, then 6, then 7): - the FSM seeded (n, m) from OAMADDR at dot 0; nesdev: "the value of OAMADDR at this tick [65] determines the starting address". The new ROM's extra JSR/RTS (OAMDMAWithPage2) moved its $2003 write to dots 28-29 of scanline 0, after the capture. Re-seeded at dot 65. - the fourth byte copied (X) is range-tested like Y: in range, OAMADDR += 1 and stays misaligned; out of range, += 1 then & $FC. The FSM always realigned. - a start at m = 3 copied ONE byte and stopped; it now copies four, crossing into slot n + 1. The X and m = 3 defects were always there: the OLD ROM recorded them as a PASS. FAIL_MisalignedOAM_Behavior jumped out of a JSR'd routine without popping the return address (upstream "stack fix" adacbc23), so a failure in tests 2-7 returned into the test body and fell through to LDA #1. Our 144/144 included that false pass. All three are written from the ROM's comments and nesdev; the evaluation FSM is a Mesen2-derived region (ppu.rs Provenance header), and these edits to it carry no new source. EMULATION_EPOCH 2 -> 3 (ADR 0045): both fixes change bus cycles or frames. TOOLING - extract_catalog.py: upstream compressed the unofficial-opcode rows into `tblf1`/`tblf2`, storing a one-byte token for "indirect", "zeropage", "absolute", "immediate". The script returned 85 of 151 rows and exited 0 (it only checked total > 0). It now rebuilds names from the ROM's own PrintTextSpecialStrings (so the eight Unofficial Immediates rows read "immediate", as printed; addresses unchanged) and fails closed on any row-shaped line it cannot read or any empty suite. 8 self-test cases; the old asm still reproduces the old TSV byte for byte. - derive_indices.py shares that grammar (it would have mis-indexed every unofficial suite), and runs again: it read BUILD-PROVENANCE.tsv by column position, stale since subtest_identify added result_addr, so every validation compared a name with an address and it refused to run at all (reproduced at HEAD against the old asm). Columns are now found by name, and legacy-unrecorded rows (indices of an unknown older source) are not used as validations. Old and new sources now derive 149 and 151 rows; the diff is the 8 renames plus the 2 new tests. - The mirror ROM is rebuilt from f5f41dc2 (unpatched rebuild reproduces upstream's md5 a3635c87; budget header 1, call site 5, fill 29, elsewhere 0; mirror control 146 of 146). - BUILD-PROVENANCE.tsv: sprite-eval-misaligned-oam settles on frame 211 (212), from the generator; verdict unchanged (Pass), the only field moved. VERIFIED - --features test-roms --release: 3,234 passed, 1 failed, 11 ignored; the failure was that settle frame, regenerated here; the provenance test then passes 4/4. AccuracyCoin 146/146 (KNOWN_FAILING empty). - misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x: each of the three mutants (dot-0 seed, one-byte copy, unconditional realign) fails at its own assertion. - The old ROM still reads 144/144 on the fixed build. - fmt; clippy (workspace, and the harness with test-roms); rustdoc -D warnings; thumbv7em no_std build; markdownlint. NOT DONE HERE - The sibling (RustyNES_MiSTer): its RTL already seeds at the end of the clear and copies four bytes, but never applies the X out-of-range & $FC; the pin move will show it. Sibling sprint. - The 31 legacy sub-test ROMs are not rebuilt. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 39 +++++ crates/rustynes-core/src/bus.rs | 42 +++++- crates/rustynes-core/src/bus_snapshot.rs | 23 ++- crates/rustynes-core/src/hardware_options.rs | 3 +- crates/rustynes-ppu/src/ppu.rs | 123 +++++++++++++++- .../src/accuracy_coin.rs | 2 +- .../src/accuracy_coin_catalog.rs | 48 ++++--- .../src/bin/accuracycoin_status.rs | 4 +- .../tests/accuracycoin.rs | 14 +- .../tests/accuracycoin_mirror.rs | 8 +- docs/STATUS.md | 2 +- docs/apu-2a03.md | 19 +++ docs/ppu-2c02.md | 23 ++- docs/scheduler.md | 4 +- scripts/accuracycoin-build/derive_indices.py | 78 +++++++++- scripts/accuracycoin-build/extract_catalog.py | 136 +++++++++++++++++- tests/roms/AccuracyCoin/README.md | 21 ++- tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv | 18 +-- .../mirror/AccuracyCoin-mirror.nes | Bin 40976 -> 40976 bytes tests/roms/AccuracyCoin/mirror/README.md | 10 +- .../sub-tests/BUILD-PROVENANCE.tsv | 8 +- tests/roms/accuracycoin/AccuracyCoin.nes | Bin 40976 -> 40976 bytes tests/roms/accuracycoin/RUNTIME.md | 24 +++- to-dos/ROADMAP.md | 6 +- to-dos/plans/v3.1.0-plan.md | 3 +- 25 files changed, 580 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0046cd6..ff8f38c66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,45 @@ cycle-accurate core later replaced. ## [Unreleased] +### Breaking + +- **Movies and netplay from v3.0.1 are refused, and so are its save states.** + `EMULATION_EPOCH` rises from 2 to 3 (the two accuracy fixes below change bus + cycles and sprite evaluation), and the BUS save-state section moves to + version 3 for the DMC latch it now carries. + +### Fixed + +- **AccuracyCoin re-synced to upstream `f5f41dc2`: 146 of 146.** The catalog + grows to 151 rows / 146 scored with `DMA Landing on Write` and `DMC Reload + Timing`. The new ROM found two defects: + - **A DMC load DMA refused by a write took three cycles, not four.** It now + enters on the next read whichever half that is (`DMA Landing on Write` + test 9), found by a per-cycle comparison with TriCNES's output. + - **Misaligned sprite evaluation.** Evaluation now takes its start from + OAMADDR at dot 65 (it used dot 0), copies four bytes from a start at the + last byte of a slot (it copied one), and stays misaligned after an + in-range X (it always realigned). The old ROM recorded these failures as + a pass: its fail path returned into the test without popping the return + address, fixed upstream in `adacbc23`. +- **The AccuracyCoin tooling reads upstream's new row macros.** + `extract_catalog.py` returned 85 of 151 rows and exited 0 when upstream + compressed the unofficial-opcode rows into `tblf1` / `tblf2`; it now + rebuilds their names from the ROM's own string table and aborts on any row + it cannot read. `derive_indices.py` shares that grammar, and no longer + refuses to run: it read the provenance TSV's columns by position, which went + stale when an address column was added. The eight `Unofficial Immediates` + rows are now spelled `immediate`, as the ROM prints them. +- **The AccuracyCoin mirror ROM is rebuilt from `f5f41dc2`.** + +### Added + +- **A test now enforces the emulation-epoch rule** (`T-EPOCH-FINGERPRINT`). + It fingerprints seven test ROMs (frames, audio, RAM, CPU cycles) and fails + when that output moves while `EMULATION_EPOCH` still equals the last + release's, refusing a re-bless in that state. Until now the rule was kept + by hand. + ## [3.0.1] - 2026-10-07 - "Mortar" (the open items closed, one game's graphics fixed, the last MMC3 rule exception tested in the MiSTer core, Rust 1.99 everywhere, every unanswered bot review answered, and a roadmap to v4.0.0) A maintenance release on v3.0.0. It closes the items v3.0.0 left open: diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index ae2de7726..e8fa9304d 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -624,6 +624,21 @@ pub struct SystemBus { /// re-read or the actual sample fetch. Read and written by the unified /// DMA engine (`unified_dma_cycle_impl`). dmc_halt: bool, + /// v3.1.0 (`AccuracyCoin` "DMA Landing on Write", test 9): a pending LOAD + /// DMC DMA reached the get half on which it would have entered, but that + /// cycle was a CPU WRITE, and RDY cannot halt a write. The load then + /// enters on the very next read whichever half it is, so a load refused + /// by one write takes four cycles (`[Put (halt)] [Get] [Put] [Get]`) + /// instead of being deferred a second cycle to its get half and taking + /// three. Without this latch the CPU ran one real cycle the hardware + /// spends halted. Set in [`Bus::write`], consumed by the DMC entry in + /// `unified_dma_cycle_impl`, and cleared by the next CPU read either way. + /// + /// Written from the test ROM's own description (`TEST_DMALandingOnWrite` + /// test 9 and its cycle comments) and pinned by a black-box per-cycle + /// comparison against `TriCNES`'s output at the test's `STA $5000`. No + /// emulator source was consulted. + dmc_load_write_delayed: bool, /// W3-Stage-1 (`mc-r1-dma-unified`): the unified engine's OAM-DMA-active /// flag (`TriCNES` `DoOAMDMA` once latched). The 513/514 length is EMERGENT /// from `uni_oam_halt`/`uni_oam_aligned` + the per-cycle dispatch — no @@ -966,6 +981,7 @@ impl SystemBus { uni_oam_addr: 0, cpu_2a03_revision: Cpu2A03Revision::default(), dmc_halt: false, + dmc_load_write_delayed: false, genie_codes: BTreeMap::new(), #[cfg(feature = "irq-timing-trace")] irq_snapshot_apu_at_low: false, @@ -1179,6 +1195,7 @@ impl SystemBus { self.last_read_addr = 0; self.in_dmc_dma = false; self.dmc_halt = false; + self.dmc_load_write_delayed = false; self.controller_write_pending = 0; self.controller_write_value = 0; // v2.9.8 — the ports' last-read stamps are bus cycles of the OLD @@ -2769,6 +2786,7 @@ impl SystemBus { // the engine feature is off) so the BUS section layout is // identical across feature builds. dmc_halt: self.dmc_halt, + dmc_load_write_delayed: self.dmc_load_write_delayed, uni_oam_active: self.uni_oam_active, uni_oam_halt: self.uni_oam_halt, uni_oam_aligned: self.uni_oam_aligned, @@ -2800,6 +2818,7 @@ impl SystemBus { // same inactive state the clear imposed -- but a restored blob // reproduces them EXACTLY instead of by assumption. self.dmc_halt = s.dmc_halt; + self.dmc_load_write_delayed = s.dmc_load_write_delayed; self.uni_oam_active = s.uni_oam_active; self.uni_oam_halt = s.uni_oam_halt; self.uni_oam_aligned = s.uni_oam_aligned; @@ -3434,10 +3453,13 @@ impl SystemBus { // the original activation). let dmc_serviceable = self.apu.dmc_dma_serviceable(); if self.apu.dmc_dma_pending() && dmc_serviceable && !self.in_dmc_dma { - let defer_load = self.apu.dmc_dma_is_load() && dmc_noop_half; + // A load refused by a write enters here regardless of the half. + let defer_load = + self.apu.dmc_dma_is_load() && dmc_noop_half && !self.dmc_load_write_delayed; if !defer_load { self.in_dmc_dma = true; self.dmc_halt = true; + self.dmc_load_write_delayed = false; self.capture_deferred_dma_replay(); } } @@ -4188,10 +4210,26 @@ impl Bus for SystemBus { /// [`Bus::cpu_clock`]; Phase 3 will split the drain out of `cpu_read`). /// Phase 1 delegates to the legacy path so the contract compiles. fn read(&mut self, addr: u16) -> u8 { + // A CPU read cycle ran, so any write-refused load either entered on + // the DMA cycles before it (clearing the latch there) or was not + // serviceable; the latch spans exactly one write-to-read boundary. + self.dmc_load_write_delayed = false; self.cpu_read(addr) } fn write(&mut self, addr: u16, value: u8) { + // RDY cannot halt a write. A pending load that would have entered on + // this cycle (the get half: the access-point label is `!put_cycle`, + // as in `unified_dma_cycle_impl`) is refused, and enters on the next + // read without the get-half deferral (`dmc_load_write_delayed`). + if self.apu.dmc_dma_pending() + && self.apu.dmc_dma_is_load() + && self.apu.dmc_dma_serviceable() + && !self.in_dmc_dma + && !self.apu.put_cycle() + { + self.dmc_load_write_delayed = true; + } self.cpu_write(addr, value); } @@ -4398,6 +4436,8 @@ impl Bus for SystemBus { && self.apu.dmc_dma_is_load() && lands_on_noop_half && !self.in_dmc_dma + // A load refused by a write may not be deferred again. + && !self.dmc_load_write_delayed } } diff --git a/crates/rustynes-core/src/bus_snapshot.rs b/crates/rustynes-core/src/bus_snapshot.rs index 919c968b2..40cf634f5 100644 --- a/crates/rustynes-core/src/bus_snapshot.rs +++ b/crates/rustynes-core/src/bus_snapshot.rs @@ -18,6 +18,16 @@ //! the OAM-DMA owed-cycle counter and byte index (the unified engine's //! length is emergent), and `dma_mc_consumed` (structurally zero since //! v2.0.0, and decoded as zero regardless since v2.7.0). +//! +//! # Version 3 (v3.1.0) +//! +//! Appends one byte after the internal data bus: the DMC load-DMA +//! write-refusal latch (`dmc_load_write_delayed`). It outlives an +//! instruction, because the refusing write is the last cycle of a store and +//! the latch is consumed by the next opcode fetch, so a snapshot at that +//! boundary without it would restore a three-cycle load where the machine +//! was owed a four-cycle one. Version 2 is refused rather than read with a +//! default, as the version-2 rules above require. use crate::bus::SystemBus; use crate::controller::Controller; @@ -30,9 +40,9 @@ use alloc::vec::Vec; /// Schema version for the BUS section payload. /// -/// 2 since v2.9.8 (ADR 0042): see the module docs for what changed. A -/// version-1 section is refused with [`SnapshotError::VersionMismatch`]. -pub const BUS_SECTION_VERSION: u8 = 2; +/// 3 since v3.1.0, 2 since v2.9.8 (ADR 0042): see the module docs for what +/// changed. An older section is refused with [`SnapshotError::VersionMismatch`]. +pub const BUS_SECTION_VERSION: u8 = 3; /// Largest encoding of one port's expansion device in the BUS section. /// @@ -134,6 +144,8 @@ pub fn encode_bus(bus: &SystemBus) -> Vec { // `open_bus`, because a DMC DMA fetch drives only the external bus, and a // `$4015` read takes bit 5 from this one. w.u8(s.internal_data_bus); + // v3.1.0 (version 3): the DMC load-DMA write-refusal latch. + w.u8(u8::from(s.dmc_load_write_delayed)); w.into_vec() } @@ -447,6 +459,7 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> } bus.set_four_score_pending(fs); let internal_data_bus = r.u8()?; + let dmc_load_write_delayed = r.bool()?; if r.remaining() != 0 { return Err(SnapshotError::SectionInvalid { tag: "BUS ".into(), @@ -469,6 +482,7 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> four_score_idx, four_score_sig, dmc_halt, + dmc_load_write_delayed, uni_oam_active, uni_oam_halt, uni_oam_aligned, @@ -533,6 +547,9 @@ pub struct BusMiscState { /// W3-Stage-4 (2026-06-10): the DMC-DMA halt latch (a DMC DMA is /// pending/halted and waiting for its GET slot). pub dmc_halt: bool, + /// v3.1.0 (BUS version 3): a pending load DMC DMA was refused by a CPU + /// write and enters on the next read whichever half it is. + pub dmc_load_write_delayed: bool, /// W3-Stage-4: unified DMA engine (`mc-r1-dma-unified`) — OAM DMA active /// (`TriCNES` `DoOAMDMA`). pub uni_oam_active: bool, diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index 4b3bfd374..8603907cf 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -95,7 +95,8 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize; /// | --- | --- | --- | /// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) | /// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) | -pub const EMULATION_EPOCH: u32 = 2; +/// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) | +pub const EMULATION_EPOCH: u32 = 3; /// Every host-settable option that changes what the emulated console does. /// diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 190dca3bb..4e405aa18 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -5762,6 +5762,23 @@ impl Ppu { } } 65..=256 => { + if self.dot == 65 { + // v3.1.0: OAMADDR AT TICK 65 sets where evaluation + // starts (nesdev "PPU registers" -> OAMADDR, "Values + // during rendering"), so re-seed `(n, m)` here. The dot-0 + // capture above stays as the reset value, but a `$2003` + // write (or a rendering-time `$2004` bump) during the + // dots 1-64 clear must still move the start. Until + // v3.1.0 only the dot-0 value counted, which was + // invisible while every test wrote `$2003` before dot 0: + // AccuracyCoin f5f41dc2 moved its "Misaligned OAM + // behavior" write to dots 28-29 of scanline 0 (one + // `JSR`/`RTS` pair later) and test 3 failed, evaluating + // from the stale address. The OAM-bus model above has + // always seeded at cycle 65. + self.sprite_eval_n = (self.oam_addr >> 2) & 0x3F; + self.sprite_eval_m = self.oam_addr & 0x03; + } if !self.sprite_eval_done { let next_line: i16 = if self.scanline == self.region.prerender_line() { -1 @@ -5881,7 +5898,23 @@ impl Ppu { // Finished this sprite. found was already // incremented when the y-byte landed. self.sprite_eval_copying = false; - self.sprite_eval_m = 0; + // v3.1.0: the fourth byte copied is the X position, and + // the PPU range-tests it exactly as it tests Y. Out of + // range: OAMADDR += 1 then &= $FC, re-aligning. IN range: + // only += 1, so a misaligned start STAYS misaligned + // (AccuracyCoin "Misaligned OAM behavior" tests 4-7, the + // "+4* behavior ... Only +1 with the X Position" rule, + // stated in the ROM's comments). `m` already holds the + // += 1 (the `m == 4` wrap above covers the aligned + // case, where both rules agree), so only the + // out-of-range case clears it. Until v3.1.0 this cleared + // `m` unconditionally; the ROM's pre-f5f41dc2 fail path + // returned into the test body without popping its return + // address, which recorded that failure as a pass. + let x_row = next_line - (latch as i16); + if !(x_row >= 0 && x_row < sprite_height) { + self.sprite_eval_m = 0; + } // Under feature: the m==4 wrap above already // advanced n once. Don't double-increment. // Under legacy: m never wrapped, so n advances @@ -5937,10 +5970,15 @@ impl Ppu { { self.sprite_eval_m += 1; if self.sprite_eval_m == 4 { - // Wrapped past end of sprite — already - // "copied" the whole sprite from its - // misaligned start. Advance n, reset m. - self.sprite_eval_copying = false; + // The Y byte was the LAST byte of slot `n` + // (evaluation started at m = 3). OAMADDR steps + // on into slot n + 1 and the copy continues: + // the PPU copies four bytes whatever the + // alignment. Until v3.1.0 this ended the copy + // here, putting one byte in secondary OAM + // instead of four (AccuracyCoin "Misaligned OAM + // behavior" test 7, offset 3; masked like test + // 6 by the ROM's pre-f5f41dc2 fail path). self.sprite_eval_m = 0; if self.sprite_eval_n == 63 { self.sprite_eval_done = true; @@ -6595,6 +6633,81 @@ mod tests { ); } + /// v3.1.0 — the three misaligned-evaluation rules the `AccuracyCoin` + /// `f5f41dc2` re-sync exposed, each pinned on its own so a regression + /// names the rule it broke: + /// + /// 1. evaluation starts at OAMADDR **as of dot 65**, not dot 0 (nesdev + /// "PPU registers" -> OAMADDR), so a write during the clear counts; + /// 2. an in-range Y copies **four** bytes whatever the alignment, also from + /// `m = 3`, where the copy crosses into slot `n + 1`; + /// 3. the fourth byte (X) is range-tested: in range, OAMADDR only steps by + /// one and stays misaligned; out of range, it steps and ANDs with `$FC`. + /// + /// Each assertion failed against the pre-v3.1.0 FSM (dot-0 seed, a + /// one-byte copy from `m = 3`, an unconditional realign). + #[test] + fn misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x() { + /// A PPU on scanline 10 whose dot-0 reset has already run with + /// OAMADDR 0, so only a later seed can pick up `oam_addr`. + fn ppu_after_dot0(oam_addr: u8, oam: &[(usize, u8)]) -> Ppu { + let mut ppu = Ppu::new(PpuRegion::Ntsc); + ppu.mask = PpuMask::SHOW_SPRITE; + ppu.scanline = 10; + ppu.oam.fill(0xFF); + for &(i, v) in oam { + ppu.oam[i] = v; + } + ppu.oam_addr = 0; + ppu.dot = 0; + ppu.tick_sprite_eval_per_dot(); + // The write lands during the clear, after the dot-0 reset. + ppu.oam_addr = oam_addr; + ppu + } + fn run_dots(ppu: &mut Ppu, from: u16, to: u16) { + for d in from..=to { + ppu.dot = d; + ppu.tick_sprite_eval_per_dot(); + } + } + + // (1) Seed at dot 65: OAMADDR 2 written after dot 0. Y at OAM[2] is in + // range for scanline 10 (Y = 8), and the walk must start there. + let mut ppu = ppu_after_dot0(0x02, &[(2, 8), (3, 0x11), (4, 0x22), (5, 0x33)]); + run_dots(&mut ppu, 65, 66); + assert_eq!( + ppu.secondary_oam[0], 8, + "evaluation must read its first Y from OAMADDR as of dot 65 (OAM[2])" + ); + + // (2) Four bytes from m = 3: OAM[3] is Y, OAM[4..=6] belong to slot 1. + let mut ppu = ppu_after_dot0(0x03, &[(3, 8), (4, 0xA1), (5, 0xA2), (6, 0xA3)]); + run_dots(&mut ppu, 65, 72); + assert_eq!( + ppu.secondary_oam[..4], + [8, 0xA1, 0xA2, 0xA3], + "a misaligned in-range sprite copies four bytes, across the slot edge" + ); + + // (3) X range test, from OAMADDR 1: Y = OAM[1], X = OAM[4]. + let x_case = |x: u8| { + let mut ppu = ppu_after_dot0(0x01, &[(1, 8), (2, 0x11), (3, 0x22), (4, x)]); + run_dots(&mut ppu, 65, 72); + u16::from(ppu.sprite_eval_n) * 4 + u16::from(ppu.sprite_eval_m) + }; + assert_eq!( + x_case(8), + 0x05, + "X in range: OAMADDR += 1 only, so the walk stays misaligned at $05" + ); + assert_eq!( + x_case(0xF0), + 0x04, + "X out of range: OAMADDR += 1 then & $FC, realigning to $04" + ); + } + /// v2.6.18 — the depth-2 rendering-gate pipeline must SHIFT, not freeze. /// /// Drives the named pair directly rather than `tick`, so it needs no bus diff --git a/crates/rustynes-test-harness/src/accuracy_coin.rs b/crates/rustynes-test-harness/src/accuracy_coin.rs index 82be73b77..f89a2bb6e 100644 --- a/crates/rustynes-test-harness/src/accuracy_coin.rs +++ b/crates/rustynes-test-harness/src/accuracy_coin.rs @@ -262,7 +262,7 @@ pub fn run_battery_with_budget(max_frames: u64) -> BatteryResult { /// for backward-compatibility (and as a cross-check), but new /// diagnostic tooling should prefer the RAM-direct path because it /// (a) is independent of the result-grid display layout, (b) decodes -/// per-test names + error codes, and (c) covers all 144 tests rather +/// per-test names + error codes, and (c) covers all 146 tests rather /// than the subset visible on the summary screen. /// /// # Panics diff --git a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs index af4ef50d6..093abe9e1 100644 --- a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs +++ b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs @@ -3,17 +3,18 @@ //! Vendored from upstream `100thCoin/AccuracyCoin` (MIT licensed). The //! list mirrors `AccuracyCoin.asm`'s 22 `Suite_*` pages: each page //! contributes a header string + a sequence of `table "name", $FF, -//! result_addr, run_addr` macro entries. Total: 149 entries across 22 -//! suites. +//! result_addr, run_addr` macro entries (and, since upstream `f5f41dc2`, the +//! byte-saving `tblf1` / `tblf2` variants of it). Total: 151 entries across +//! 22 suites. //! //! ## Source of truth //! //! The authoritative list lives next to the ROM at -//! `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` as a 149-line +//! `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` as a 151-line //! `(suitenameresult_addr)` file extracted from upstream -//! `AccuracyCoin.asm` by the recipe documented inline in -//! `tests/roms/AccuracyCoin/README.md` (walk each `Suite_*`/`table` block, -//! resolving `result_symbol` to its `result_X = $ADDR` definition). +//! `AccuracyCoin.asm` by `scripts/accuracycoin-build/extract_catalog.py` +//! (walk each `Suite_*` block's row macros, resolving `result_symbol` to its +//! `result_X = $ADDR` definition). //! This module embeds that file via `include_str!` and parses it //! lazily so the in-code catalog cannot drift from the on-disk source. //! @@ -75,7 +76,8 @@ pub struct CatalogEntry { /// pointer's high byte against `3` and branching past the test when it matches. /// Five catalog rows (the whole `Power On State` suite: `PPU Reset Flag`, /// `CPU RAM`, `CPU Registers`, `PPU RAM`, `Palette RAM`) point here, so the -/// catalog's 149 rows carry **144 scored results and one shared scratch byte**. +/// catalog's 151 rows carry **146 scored results and one shared scratch byte** +/// (149 and 144 before the v3.1.0 re-sync to upstream `f5f41dc2`). /// /// ## Why this constant had to exist /// @@ -88,7 +90,8 @@ pub struct CatalogEntry { /// read as 149 of 149 and the other as 144 of 144 — from one ROM, with nothing /// wrong in between. /// -/// Excluding the sentinel makes the count **144 in both windows**. It changes no +/// Excluding the sentinel made the count **144 in both windows** (146 since +/// v3.1.0). It changes no /// verdict about any real test, and it removes five rows from every "entry for /// entry" claim that were never entries. pub const RESULT_DRAW_TEST: u16 = 0x03FF; @@ -105,7 +108,7 @@ impl CatalogEntry { } } -/// Number of catalog rows that carry a real result: 149 rows, 144 scored. +/// Number of catalog rows that carry a real result: 151 rows, 146 scored. /// /// # Panics /// @@ -203,7 +206,7 @@ impl TestStatus { } } -/// Return the catalog of all 149 AccuracyCoin tests, in `TableTable` +/// Return the catalog of all 151 AccuracyCoin tests, in `TableTable` /// order. /// /// The result is built once (on first call) and cached for the @@ -247,7 +250,7 @@ pub fn catalog() -> &'static [CatalogEntry] { /// Look up a catalog entry by zero-based `TableTable` index. /// -/// Returns `None` if `index >= 149`. +/// Returns `None` if `index >= 151`. #[must_use] pub fn entry(index: usize) -> Option<&'static CatalogEntry> { catalog().get(index) @@ -269,7 +272,7 @@ pub fn suite_size(suite: &str) -> usize { catalog().iter().filter(|e| e.suite == suite).count() } -/// Decode the 149-entry result vector by reading each catalog entry's +/// Decode the 151-entry result vector by reading each catalog entry's /// [`CatalogEntry::result_addr`] from `ram` (which must be the NES's /// 2 KiB CPU RAM borrowed via `Nes::bus().ram_bytes()`). /// @@ -291,13 +294,13 @@ pub fn decode_results(ram: &[u8]) -> Option> { /// Aggregated counts derived from a decoded results vector. /// -/// **Every field counts SCORED rows only**, so `total` is 144 rather than the -/// catalog's 149 and `not_run` excludes the five `Power On State` rows that +/// **Every field counts SCORED rows only**, so `total` is 146 rather than the +/// catalog's 151 and `not_run` excludes the five `Power On State` rows that /// share [`RESULT_DRAW_TEST`]. [`failing_tests`] uses the same set, so the /// counts here and the named list there cannot disagree. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct RamResultSummary { - /// Total number of catalog entries (always 149 if the catalog is + /// Total number of catalog entries (always 151 if the catalog is /// fully loaded). pub total: u32, /// Tests that wrote `$01` (clean pass). @@ -345,8 +348,8 @@ impl RamResultSummary { #[must_use] /// Summarise a decoded vector, counting **scored rows only**. /// -/// The five rows sharing [`RESULT_DRAW_TEST`] are excluded, so `total` is 144 -/// rather than the catalog's 149. Including them made every count a function of +/// The five rows sharing [`RESULT_DRAW_TEST`] are excluded, so `total` is 146 +/// rather than the catalog's 151. Including them made every count a function of /// when the run was sampled — see the constant's rustdoc for the measurement. pub fn summarise(statuses: &[TestStatus]) -> RamResultSummary { let mut s = RamResultSummary { @@ -439,8 +442,8 @@ mod tests { use super::*; #[test] - fn catalog_has_exactly_149_entries() { - assert_eq!(catalog().len(), 149, "AccuracyCoin catalog size drifted"); + fn catalog_has_exactly_151_entries() { + assert_eq!(catalog().len(), 151, "AccuracyCoin catalog size drifted"); } #[test] @@ -465,6 +468,11 @@ mod tests { assert!(names.contains(&"$03 SLO indirect,X")); assert!(names.contains(&"Internal Data Bus")); assert!(names.contains(&"$2007 Stress Test")); + // v3.1.0 (upstream f5f41dc2): the two new `CPU Behavior 2` tests, and a + // row rebuilt from a `tblf1` token, spelled as the ROM prints it. + assert!(names.contains(&"DMA Landing on Write")); + assert!(names.contains(&"DMC Reload Timing")); + assert!(names.contains(&"$0B ANC immediate")); } #[test] @@ -562,7 +570,7 @@ mod tests { ram[e0.result_addr as usize] = 0x01; ram[e1.result_addr as usize] = (3 << 2) | 0x02; // fail code 3 let statuses = decode_results(&ram).expect("decode"); - assert_eq!(statuses.len(), 149); + assert_eq!(statuses.len(), 151); assert_eq!(statuses[0], TestStatus::Pass); assert_eq!(statuses[1], TestStatus::Fail(3)); // The five Power On State tests share $03FF (left at 0x00). diff --git a/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs b/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs index 1a47a9f84..d75a0d6ae 100644 --- a/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs +++ b/crates/rustynes-test-harness/src/bin/accuracycoin_status.rs @@ -44,8 +44,8 @@ use rustynes_test_harness::accuracy_coin_catalog::{ /// `$6000-$61FF`, and the `MiSTer` core persists the whole `$6000-$7FFF` PRG-RAM /// window, so a hardware `.sav` is 8 KiB whose first 512 bytes are the vector. /// -/// Every one of the catalog's 149 result addresses falls inside `$0300-$04FF` -/// (`$03FF`-`$0495`, checked against `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv`), +/// Every one of the catalog's 151 result addresses falls inside `$0300-$04FF` +/// (`$03FF`-`$0497`, checked against `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv`), /// which is what makes the lift below lossless rather than a subset. const MIRROR_LEN: usize = 0x0200; const MIRROR_VECTOR_BASE: usize = 0x0300; diff --git a/crates/rustynes-test-harness/tests/accuracycoin.rs b/crates/rustynes-test-harness/tests/accuracycoin.rs index 98310abab..959ee9892 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin.rs @@ -77,17 +77,27 @@ const MIN_PASS_RATE: f64 = 0.60; /// the authority and this prose must agree with it; the drift mechanism was /// that the list was emptied and the sentence above it was not. /// +/// **v3.1.0 re-sync to upstream `f5f41dc2`: 146 of 146.** The catalog grew +/// 144 -> 146 scored rows (`DMA Landing on Write`, `DMC Reload Timing`). +/// The new ROM failed two tests, both fixed in the same change: `DMA Landing +/// on Write` (a load DMA refused by a write took three cycles, not four) and +/// `Misaligned OAM behavior`, which the old ROM had recorded as a PASS while +/// tests 3, 6 and 7 failed: its fail path returned into the test body +/// without popping the return address (upstream `adacbc23`), so every +/// failure after test 2 fell through to the pass at the end. +/// /// Asserted alongside the known-failing set so that a battery which /// *under-executes* (early bail, skipped suite, decoder that stops /// assigning cells) fails as loudly as one that regresses — an empty /// failing list is not by itself evidence of success. Re-bless this /// together with `docs/STATUS.md` if an upstream ROM update changes the /// catalog. -const EXPECTED_PASS_COUNT: u32 = 144; +const EXPECTED_PASS_COUNT: u32 = 146; /// The `AccuracyCoin` tests this build is known to fail, pinned BY NAME. /// -/// **Empty as of v2.6.18** — the battery reads 144/144. The list stays, and +/// **Empty as of v2.6.18** — the battery reads 144/144 (146/146 since the +/// v3.1.0 re-sync). The list stays, and /// stays documented, because it is the mechanism that makes a future gap /// explicit rather than absorbed into a lowered count. /// diff --git a/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs b/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs index 05f33cd07..3a546e82b 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin_mirror.rs @@ -27,7 +27,7 @@ //! and four things are checked: //! //! 1. the raw `$0300-$04FF` window is byte-identical between the two ROMs; -//! 2. the decoded 149-entry status vector is identical entry for entry; +//! 2. the decoded 151-entry status vector is identical entry for entry; //! 3. the mirror at `$6000-$61FF` reproduces the patched run's own live window; //! 4. the mirror is not vacuous. //! @@ -197,12 +197,12 @@ fn end_to_end_through_the_comparator(oracle_ram: &[u8], hardware_shaped_sav: &[u and the mirrored save.\nstdout:\n{stdout}\nstderr:\n{stderr}" ); // And the coverage is full, read off the comparator's own sentence rather - // than inferred from its exit code. 144 scored rows, all executed on both + // than inferred from its exit code. 146 scored rows, all executed on both // sides -- the catalog's other five share upstream's omit-sentinel // `result_DrawTest` and are excluded by the catalog, not waived here. assert!( - stdout.contains("144 of 144 scored entries executed on both sides"), - "the comparator's coverage sentence is not the expected 144 of 144.\nstdout:\n{stdout}" + stdout.contains("146 of 146 scored entries executed on both sides"), + "the comparator's coverage sentence is not the expected 146 of 146.\nstdout:\n{stdout}" ); assert!( stdout.contains("(0 on neither"), diff --git a/docs/STATUS.md b/docs/STATUS.md index 5744c484d..fd650d4fb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1881,7 +1881,7 @@ without panicking (no `$6000` status protocol). | `mmc5` (smoke) | 3 | — | — | 3 | `mapper_mmc5test_v1.nes`, `mapper_mmc5test_v2.nes`, `mapper_mmc5exram.nes` from `christopherpow/nes-test-roms/mmc5test/`. Visual-only; smoke-tested. Deep features (split-screen ExGrafix, audio extension) tested via in-tree mapper unit tests. | | `holy_mapperel` | 19 | — | — | 19 | Damian Yerrick / tepples cartridge-PCB-assembly test (zlib license). 17 release ROMs across mappers 0/1/2/3/4/7/9/10/34/66/69, plus two 512 KiB MMC1 images (SUROM `M1_P512K_CR8K_S8K`, SXROM `M1_P512K_CR8K_S32K`) built from the v0.02 source tag in v2.7.2, whose build reproduces all 17 release ROMs byte-for-byte. Each screen is pinned by one combined framebuffer snapshot, with settled and non-blank guards, and each was read as `PASS 0000` by eye when it was pinned; there is no status-byte assertion, so none of the 19 is a strict pass by this table's definition (the column said 17 until v2.7.2, against this row's own "smoke-tested only"). Track B1. | | `vrc24test` | — | — | — | — | **Skipped (Track B1)**: link rot. AWJ's original forum attachment (id=10017 on forums.nesdev.org/viewtopic.php?p=203716) is auth-walled; the deletion is documented at archive.nes.science. No GitHub mirror found. | -| `AccuracyCoin` | 1 | 1 | — | — | 100thCoin / Chris Siebert single-NROM accuracy battery (MIT license, 149 tests across 22 suites + 5 visual-only `Power On State` tests sharing `$03FF`; the v2.0.1 upstream re-sync grew the catalog from 144 to 146 rows / 139 to 141 assigned tests, adding the PPU "ALE + Read" and "Hybrid Addresses" tests, and the **2026-09 re-sync** to upstream `69c8860` grew it again to 149 rows / 144 assigned across 22 suites — two new pages, `Advanced Background Evaluation` and `Advanced Sprite Evaluation`, which re-home eleven existing PPU tests that had outgrown `PPU Misc.`, plus three genuinely new tests). Interactive (D-Pad menu); the harness presses `START` to "run all tests on the ROM" then takes two parallel measurements. **(1) Framebuffer decoder** reads the 10×16 on-screen result grid by exact-pixel colour (5-colour palette: `#64A0FF` = pass, `#4F1000` = fail, `#DC834C` = partial-pass, `#4C4C4C` = no-test / not-run, `#FFFFFF` = border); this is the legacy path and has a known grid-stride bug that under-samples by ~31 cells. **(2) RAM-direct decoder** reads each test's result byte from its fixed CPU-RAM address (catalogued from upstream `AccuracyCoin.asm` in `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` and `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` — 149 `(suite, name, addr)` triples, regenerated by `scripts/accuracycoin-build/extract_catalog.py`) and decodes per-test pass/fail/error-code names + per-suite breakdowns. This is the authoritative path. **Current measured pass rate (RAM-direct): 100.00% (144/144)** on the default build — the two upstream PPU tests "ALE + Read" and "Hybrid Addresses" (briefly the only gaps at 139/141 after the v2.0.1 catalog re-sync) were **closed in v2.0.3** by promoting the 2-cycle-ALE PPU fetch model to the unconditional default. The `90.65%`, `84.17%` and the trajectory figures below are historical engine-lineage milestones (the pre-promotion v1.0.0-rc2 / Session-26 era), retained as history. Historical trajectory: `64.03%` (post-D2 baseline) → `67.63%` (post-D3, 7 6502 bus-pattern fixes) → `69.06%` (post-Phase-3 OAM DMA parity fix, +1 strict test flipped) → `69.78%` (post-FSM-fix recovery, +1 sprite-related sub-test flipped as a side-benefit of the `crates/rustynes-ppu/src/ppu.rs` dot-64 reset removal) → `76.98%` (post-Cascade-B DMC DMA scheduler, commit `9b0c81c` — closes all 8 tests in the `APU Registers and DMA tests` suite + 3 net elsewhere as side-benefits; +11 tests flipped) → `78.42%` (post-Cascade-A OAMADDR-during-rendering reset, commit `f29f7ca` — hardware-accurate per nesdev: OAMADDR is reset to 0 during dots 257-320 of every rendered scanline; +2 tests flipped — Sprite overflow behavior PASSES, Sprite 0 Hit advances from error 1 → error 13) → `79.14%` (post-session-7 OAMADDR-walks-during-eval + $4-aligned `$2004` write, commit `c230489` — closes `Address $2004 behavior` with code 16; +1 net flip) → `79.86%` (post-session-7 RMW ABS,X/Y unfixed-address dummy read, commit `32d5b18` — 18 RMW opcodes get the canonical cycle-4 unfixed-address dummy; flips `APU Tests :: Controller Clocking` and advances `Implied Dummy Reads` 2→3 + `Frame Counter IRQ` 6→7 via the SLO $4015,X bracket; +1 net flip) → `82.73%` (post-session-8 BG-pipeline cycle-9 reload + post-emit shift, commit `086ce4d` — fixes the long-standing 1-column BG pixel off-by-one identified in `docs/audit/cascade-a-investigation-2026-05-19.md`; flips `Sprite 0 Hit behavior` + `Sprite overflow behavior` + `Suddenly Resize Sprite` + `$2007 read w/ rendering`; +4 net flips, +2.87pp) → `83.45%` (post-session-24 Controller Strobing M2-low-defer write, Session-24 Phase 3 — deferred `$4016` commit buffer on `LockstepBus` mirrors Mesen2's `NesControlManager::ProcessWrites`; flips `APU Tests :: Controller Strobing` from `[error 4]` to PASS; +1 net flip) → **`84.17%` (post-session-26 Sprint 2 iter 5 Frame-Counter-IRQ split, 2026-05-23 — separates `FrameCounter::irq_flag` ($4015 bit 6 visibility) from `FrameCounter::irq_line_active` (CPU IRQ source driver) so Tests I/J/K/L/M/N/O all PASS without spuriously asserting the CPU IRQ line on inhibited frame-counter cycles; flips `APU Tests :: Frame Counter IRQ` from `[error 19]` to PASS; +1 net flip)**. Session-26 Sprint 2 iter 4 (APU Register Activation OAM-DMA chip-select gate) advanced the same suite's APU Register Activation entry internally from `[error 4]` to `[error 6]` but did not flip the catalog-headline metric. The previous `75.93%` headline reflected the framebuffer decoder's stride bug, not real accuracy. Strict floor in CI is **60%** — see `crates/rustynes-test-harness/tests/accuracycoin.rs::MIN_PASS_RATE`. the v0.9.x 80% target and the v1.0.0 90% gate were both cleared, and the default build now measures **100.00% (144/144)** — the master-clock core is the default, the former C1 + sub-cycle residuals are closed, and the two v2.0.1 PPU tests were closed in v2.0.3 (see "Accuracy residuals" below). **No open AccuracyCoin gap, as of v2.6.18.** The 2026-09 re-sync opened two, both in the new `Advanced Sprite Evaluation` page. `Misaligned OAM2 Address` ($0495) closed in v2.6.17 by modelling `OAM2Address` as a live counter through sprite fetch. `Frozen OAM2 Increment` ($0493) closed in **v2.6.18**, and the long diagnosis this row used to carry is **RETRACTED in all three of its claims** — it said a `$2001` enable on dot 256 took effect a dot early (all four writes the ROM names are in force from the START of the dot it names), it blamed test 2 or 3 (the failure was test **4**, the false-positive guard, and the ROM omits an `INC > 2) & $3F`, `m = OAMADDR & 3`), so a - misaligned `OAMADDR` starts the walk on a tile / attribute / X byte and - the y-test reads *that* byte. If in range for the next scanline, copy - bytes 1..=3. + `m` **at dot 65** (`n = (OAMADDR >> 2) & $3F`, `m = OAMADDR & 3`; nesdev + "PPU registers" -> OAMADDR: "the value of OAMADDR at this tick determines + the starting address"), so a misaligned `OAMADDR` starts the walk on a + tile / attribute / X byte and the y-test reads *that* byte. A `$2003` + write during the dots 1-64 clear therefore still moves the start. (Until + v3.1.0 the FSM seeded at dot 0 only; AccuracyCoin `f5f41dc2` writes + `$2003` at dots 28-29 of scanline 0 and exposed it.) + - If in range for the next scanline, copy **four bytes** from the walk + position, `OAMADDR` stepping by one each, whatever the alignment: a start + at `m = 3` copies the last byte of slot `n` and the first three of slot + `n + 1` (until v3.1.0 the FSM stopped after one byte there). + - The fourth byte copied is the X position, and it is range-tested like Y. + **X in range: `OAMADDR += 1` only**, so a misaligned walk stays misaligned. + **X out of range: `OAMADDR += 1`, then AND with `$FC`**, realigning. + Aligned, both give the next multiple of four, so only misaligned OAM sees + the difference (AccuracyCoin `Misaligned OAM behavior` tests 4-7; until + v3.1.0 the FSM always realigned, and the pre-`f5f41dc2` ROM recorded the + resulting failures as a pass because its fail path did not pop its return + address). - Not in range, secondary OAM **not** full: `OAMADDR += 4`, **then AND with `$FC`** — so `n` advances and `m` is *cleared*, realigning the walk after the first out-of-range sprite. When `n` overflows to 0, evaluation diff --git a/docs/scheduler.md b/docs/scheduler.md index 8945f39ec..ec9efbca3 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -82,7 +82,9 @@ A small inner struct that tracks: Scheduling rules per `ref-docs/research-report.md` §DMA: -- DMA can only halt on a CPU read cycle. +- DMA can only halt on a CPU read cycle. A load DMA a write refuses enters + on the next read whichever half it is (four cycles after one refusing + write; `docs/apu-2a03.md`, v3.1.0). - DMC DMA gets precedence over OAM DMA. - OAM DMA: 1 halt + (0 or 1 alignment) + 256 read/write pairs = 513 or 514 cycles. - DMC DMA: 1 halt + 1 dummy + (0 or 1 alignment) + 1 read = 3 or 4 cycles. diff --git a/scripts/accuracycoin-build/derive_indices.py b/scripts/accuracycoin-build/derive_indices.py index df2e5af9d..b6ff314d9 100644 --- a/scripts/accuracycoin-build/derive_indices.py +++ b/scripts/accuracycoin-build/derive_indices.py @@ -25,11 +25,28 @@ from __future__ import annotations import argparse +import importlib.util import re import sys from pathlib import Path +def _extract_catalog(): + """Load the sibling `extract_catalog.py`, which owns the row grammar. + + Since upstream `f5f41dc2` a suite's rows come in three macros (`table`, + `tblf1`, `tblf2`), and the last two name a test by a one-byte token the + ROM expands from `PrintTextSpecialStrings`. Two copies of that grammar + would drift apart exactly as the hand-kept suite map did, so this script + reuses the extractor's patterns and token table rather than its own. + """ + path = Path(__file__).resolve().with_name("extract_catalog.py") + spec = importlib.util.spec_from_file_location("extract_catalog", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + # Upstream's "this test is not in the all-test-result-table" marker. Five # entries share it, so it is not an address and must never be used as a key. DRAW_TEST_LABEL = "result_DrawTest" @@ -60,7 +77,15 @@ def parse(asm: str): if not order: sys.exit("derive_indices: TableTable parsed to nothing -- refusing to guess") - # --- each suite's own `table` lines give the test order ----------------- + # --- each suite's own row lines give the test order -------------------- + ec = _extract_catalog() + words = ec.special_words(asm) + + def word(token: str) -> str: + if token not in words: + sys.exit(f"derive_indices: row uses {token}, which has no definition") + return words[token] + bodies: dict[str, list[tuple[str, str]]] = {} cur: str | None = None for ln in lines: @@ -78,6 +103,22 @@ def parse(asm: str): m = re.match(r'\s*table\s+"([^"]+)"\s*,\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)', ln) if m and cur: bodies[cur].append((m.group(1), m.group(2))) + continue + m = ec.RE_TBLF1_ROW.match(ln) + if m and cur: + bodies[cur].append((f"{m.group(1)} {word(m.group(2))}", m.group(3))) + continue + m = ec.RE_TBLF2_ROW.match(ln) + if m and cur: + bodies[cur].append( + (f"{m.group(1)} {word(m.group(2))}{m.group(3)}", m.group(4)) + ) + continue + # FAIL CLOSED: a row-shaped macro no pattern read would shift every + # later test index in its suite, and a sub-test ROM built from a + # shifted index runs the wrong test and writes a plausible byte. + if cur and ec.RE_ANY_ROW.match(ln): + sys.exit(f"derive_indices: {cur} has a row this script cannot read: {ln.strip()}") # --- result label -> address ------------------------------------------- results: dict[str, int] = {} @@ -106,14 +147,43 @@ def _recorded_validations() -> list[str]: """Recorded (suite, test, name) rows as `--validate` specs, or [].""" if not PROVENANCE_TSV.is_file(): return [] + # Columns are found by NAME from the generator's schema line + # (`# romenc_suiteenc_testresult_addrentry...`). Until + # v3.1.0 they were taken by position as (rom, suite, test, name), which + # went stale when `subtest_identify` added `result_addr` as column 3: every + # validation then compared a test name against an address and failed, so + # this script refused to run at all. + text = PROVENANCE_TSV.read_text(encoding="utf-8").splitlines() + names = ["rom", "enc_suite", "enc_test", "entry"] + for line in text: + head = line.lstrip("#").strip().split("\t") + if line.startswith("#") and head and head[0] == "rom" and "entry" in head: + names = head + try: + i_suite, i_test, i_name = ( + names.index("enc_suite"), names.index("enc_test"), names.index("entry") + ) + except ValueError: + sys.exit(f"derive_indices: {PROVENANCE_TSV.name} schema lacks enc_suite/enc_test/entry") + # A `legacy-unrecorded` row's indices were encoded against an upstream + # source nobody wrote down -- `implied-dummy-reads` is suite 19 test 1 in + # a build whose suite order predates the reorder -- so it is evidence + # about THAT source, not this one, and validating against it fails for a + # reason unrelated to the parser. Only rows with a recorded build commit + # are hand-checked answers. + i_commit = names.index("upstream_commit") if "upstream_commit" in names else None out = [] - for line in PROVENANCE_TSV.read_text(encoding="utf-8").splitlines(): + for line in text: if not line.strip() or line.lstrip().startswith("#"): continue cols = line.split("\t") - if len(cols) < 4: + if len(cols) <= max(i_suite, i_test, i_name): + continue + if i_commit is not None and ( + len(cols) <= i_commit or cols[i_commit].strip().startswith("legacy") + ): continue - _rom, suite, test, name = cols[0], cols[1], cols[2], cols[3] + suite, test, name = cols[i_suite], cols[i_test], cols[i_name] if suite.strip().isdigit() and test.strip().isdigit(): out.append(f"{suite.strip()}:{test.strip()}:{name.strip()}") return out diff --git a/scripts/accuracycoin-build/extract_catalog.py b/scripts/accuracycoin-build/extract_catalog.py index 045d44824..765a61acc 100755 --- a/scripts/accuracycoin-build/extract_catalog.py +++ b/scripts/accuracycoin-build/extract_catalog.py @@ -17,6 +17,16 @@ * Each `Suite_X:` block opens with `.byte "Display Name", $FF` and then carries `table "test name", $FF, result_symbol, TEST_entrypoint` rows until a bare `.byte $FF` terminator. + * Since upstream `f5f41dc2` (2026-10) the unofficial-opcode suites use two + byte-saving variants. `tblf1 "prefix", str_X, $FF, result, TEST` stores a + one-byte token for a common word, and `tblf2 "prefix", str_X, ",X", $FF, + result, TEST` appends a suffix after it. The ROM prints the token from + `PrintTextSpecialStrings` (`" indirect"`, `" zeropage"`, ...), indexed by + the token's low two bits, so the name is rebuilt from that table, not + from a word list kept here. + * Any other row-shaped line inside a suite aborts the run. A new macro + must fail loudly: this script once returned 85 of 151 rows without + complaint, because it only checked that the total was non-zero. * `result_symbol` resolves through a top-level `result_X = $ADDR` definition. @@ -40,6 +50,48 @@ r'^\s*table\s+"([^"]*)"\s*,\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)\s*,', re.M ) RE_SUITE_HEADER = re.compile(r'^\s*\.byte\s+"([^"]*)"\s*,\s*\$FF', re.M) +# `tblf1 "$07 SLO", str_ZeroPage, $FF, result_X, TEST_X` +RE_TBLF1_ROW = re.compile( + r'^\s*tblf1\s+"([^"]*)"\s*,\s*(str_[A-Za-z0-9_]+)\s*,\s*\$FF\s*,' + r"\s*(result_[A-Za-z0-9_]+)\s*,", + re.M, +) +# `tblf2 "$03 SLO", str_Indirect, ",X", $FF, result_X, TEST_X` +RE_TBLF2_ROW = re.compile( + r'^\s*tblf2\s+"([^"]*)"\s*,\s*(str_[A-Za-z0-9_]+)\s*,\s*"([^"]*)"\s*,' + r"\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)\s*,", + re.M, +) +# Any line opening with a row-like macro (`table`, `tblf1`, `tblfN`, ...). +# Used only to prove every such line was read by one of the patterns above. +RE_ANY_ROW = re.compile(r'^\s*(t[a-z]*[0-9]*)\s+"', re.M) +RE_TOKEN_DEF = re.compile(r"^(str_[A-Za-z0-9_]+)\s*=\s*\$([0-9A-Fa-f]+)", re.M) +RE_SPECIAL_STRINGS = re.compile( + r'^PrintTextSpecialStrings:\s*\n((?:\s*\.byte\s+"[^"]*"\s*\n?)+)', re.M +) + + +def special_words(asm: str) -> dict[str, str]: + """Map each `str_X` token to the word the ROM prints for it. + + The ROM's `PrintTextSpecialString` takes the token's low two bits as an + index into `PrintTextSpecialStrings`. Each entry carries a leading space + that separates it from the prefix; `parse` re-adds it as one space. + """ + tokens = {m.group(1): int(m.group(2), 16) for m in RE_TOKEN_DEF.finditer(asm)} + if not tokens: + return {} + block = RE_SPECIAL_STRINGS.search(asm) + if not block: + raise SystemExit("`str_X` tokens are defined but no PrintTextSpecialStrings table exists") + words = re.findall(r'"([^"]*)"', block.group(1)) + out = {} + for name, value in tokens.items(): + idx = value & 0x3 + if idx >= len(words): + raise SystemExit(f"{name} = ${value:02X} indexes past PrintTextSpecialStrings") + out[name] = words[idx].strip() + return out def parse(asm: str) -> list[tuple[str, str, int]]: @@ -55,6 +107,13 @@ def parse(asm: str) -> list[tuple[str, str, int]]: if not order: raise SystemExit("TableTable block contained no `.word Suite_*` rows") + words = special_words(asm) + + def word(token: str) -> str: + if token not in words: + raise SystemExit(f"row uses {token}, which has no `{token} = $NN` definition") + return words[token] + rows: list[tuple[str, str, int]] = [] for label in order: start = asm.find(f"\n{label}:") @@ -70,7 +129,30 @@ def parse(asm: str) -> list[tuple[str, str, int]]: raise SystemExit(f"{label} has no `.byte \"name\", $FF` display header") suite = header.group(1) - for name, symbol in RE_TABLE_ROW.findall(block): + found: list[tuple[int, str, str]] = [] + for m in RE_TABLE_ROW.finditer(block): + found.append((m.start(), m.group(1), m.group(2))) + for m in RE_TBLF1_ROW.finditer(block): + found.append((m.start(), f"{m.group(1)} {word(m.group(2))}", m.group(3))) + for m in RE_TBLF2_ROW.finditer(block): + found.append( + (m.start(), f"{m.group(1)} {word(m.group(2))}{m.group(3)}", m.group(4)) + ) + found.sort() + # Every row-shaped line must have been read by exactly one pattern. + candidates = [m for m in RE_ANY_ROW.finditer(block)] + if len(candidates) != len(found): + parsed = {pos for pos, _, _ in found} + missed = [ + block[m.start() : block.find("\n", m.start())].strip() + for m in candidates + if m.start() not in parsed + ] + raise SystemExit(f"{label} has row lines this script cannot read: {missed}") + if not found: + raise SystemExit(f"{label} yielded zero rows") + + for _, name, symbol in found: if symbol not in results: raise SystemExit( f'{label} / "{name}" references {symbol}, which has no ' @@ -107,6 +189,13 @@ def render(rows: list[tuple[str, str, int]]) -> str: \t.byte "Second Page", $FF \ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma \t.byte $FF + +str_Indirect = $F0 +str_ZeroPage = $F1 + +PrintTextSpecialStrings: +\t.byte " indirect" +\t.byte " zeropage" """ @@ -142,7 +231,50 @@ def self_test() -> int: assert "result_Alpha" in {m.group(1) for m in RE_RESULT_DEF.finditer(SELF_TEST_ASM)} assert len(RE_RESULT_DEF.findall(SELF_TEST_ASM)) == 3 - print("extract_catalog self-test: ok (4 cases)") + # The compressed row macros (upstream 03757ce8 / f5f41dc2, 2026-10-07): + # `tblf1` and `tblf2` substitute a one-byte token for a common word, and + # `tblf2` appends a suffix. The name is rebuilt exactly as the ROM shows it. + compressed = SELF_TEST_ASM.replace( + '\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma', + '\ttblf2 "$03 SLO", str_Indirect, ",X", $FF, result_Gamma, TEST_Gamma\n' + '\ttblf1 "$07 SLO", str_ZeroPage, $FF, result_Alpha, TEST_Alpha', + ) + rows = parse(compressed) + assert rows[:2] == [ + ("Second Page", "$03 SLO indirect,X", 0x0403), + ("Second Page", "$07 SLO zeropage", 0x0401), + ], rows + + # FAIL CLOSED on a row shape the parser does not know: a macro it cannot + # read must abort, never vanish. Until v3.1.0 this script dropped all 66 + # compressed rows silently, because only "zero rows in total" was checked. + unknown = SELF_TEST_ASM.replace("\ttable \"Gamma Test\"", "\ttblf9 \"Gamma Test\"") + try: + parse(unknown) + except SystemExit as exc: + assert "tblf9" in str(exc), exc + else: + raise AssertionError("an unknown row macro was dropped silently") + + # ...and a suite that yields no rows at all must abort. + empty = SELF_TEST_ASM.replace('\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma\n', "") + try: + parse(empty) + except SystemExit as exc: + assert "Suite_Second" in str(exc), exc + else: + raise AssertionError("a suite with zero rows was accepted") + + # An unknown word token must abort rather than guess a word. + badtok = compressed.replace(", str_ZeroPage,", ", str_Mystery,") + try: + parse(badtok) + except SystemExit as exc: + assert "str_Mystery" in str(exc), exc + else: + raise AssertionError("an unknown word token was accepted") + + print("extract_catalog self-test: ok (8 cases)") return 0 diff --git a/tests/roms/AccuracyCoin/README.md b/tests/roms/AccuracyCoin/README.md index 8d37b621d..ea382967d 100644 --- a/tests/roms/AccuracyCoin/README.md +++ b/tests/roms/AccuracyCoin/README.md @@ -8,7 +8,7 @@ diagnostic decoder needs at compile time. | File | Purpose | |------|---------| -| `SOURCE_CATALOG.tsv` | 149-row TSV mapping `(suite, name) -> result-byte address`, extracted from upstream `AccuracyCoin.asm`'s `Suite_*` blocks by `scripts/accuracycoin-build/extract_catalog.py`. `include_str!`'d by `rustynes_test_harness::accuracy_coin_catalog`. | +| `SOURCE_CATALOG.tsv` | 151-row TSV mapping `(suite, name) -> result-byte address`, extracted from upstream `AccuracyCoin.asm`'s `Suite_*` blocks by `scripts/accuracycoin-build/extract_catalog.py`. `include_str!`'d by `rustynes_test_harness::accuracy_coin_catalog`. | | `sub-tests/*.nes` | Custom-built sub-test ROMs that boot directly into one target test (bypass menu + full-battery loop). Built by `scripts/accuracycoin-build/build_sub_test_rom.py`. Used to unblock the Session-22 Mesen2 wall-time oracle blocker. Inherits upstream MIT license. See `docs/audit/session-23-custom-accuracycoin-sub-test-roms-2026-05-22.md`. | The runtime `.nes` ROM lives at [`../accuracycoin/AccuracyCoin.nes`](../accuracycoin/AccuracyCoin.nes) @@ -152,8 +152,9 @@ pass / fail breakdowns. ## Source `https://github.com/100thCoin/AccuracyCoin` (main branch; re-synced to -upstream commit `46199ae4` on 2026-09-19; previously `69c8860`, 2026-09-11, -and `71f57fb` in v2.0.1). The `46199ae4` re-sync left this catalog +upstream commit `f5f41dc2` on 2026-10-07 for v3.1.0; previously `46199ae4`, +2026-09-19, `69c8860`, 2026-09-11, and `71f57fb` in v2.0.1). The `46199ae4` +re-sync left this catalog **byte-identical** — re-running `extract_catalog.py` against the new `AccuracyCoin.asm` reproduces the committed TSV exactly, because the two upstream commits insert `INC 151 rows / 144 -> 146 +scored: two new `CPU Behavior 2` tests, `DMA Landing on Write` (`$0496`) and +`DMC Reload Timing` (`$0497`). It also changed the row grammar. To save ROM +space upstream replaced the unofficial-opcode suites' `table` rows with +`tblf1` / `tblf2`, which store a one-byte token for the words "indirect", +"zeropage", "absolute" and "immediate". `extract_catalog.py` (and +`derive_indices.py`, which shares its patterns) rebuild those names from +the ROM's own `PrintTextSpecialStrings`, so the eight `Unofficial +Immediates` rows now read `immediate` in lower case, as the ROM prints them +(`Immediate` before; the result addresses are unchanged). Before that fix +the extractor returned 85 of the 151 rows and exited 0, because it only +checked that the total was non-zero; it now aborts on any row-shaped line +it cannot read, and on any suite that yields none. + ## License MIT (same as the runtime ROM; full text in diff --git a/tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv b/tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv index 1a49da088..660ff72e5 100644 --- a/tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv +++ b/tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv @@ -71,14 +71,14 @@ Unofficial Instructions: SH* $9B SHS absolute,Y 0x0448 Unofficial Instructions: SH* $9C SHY absolute,X 0x0449 Unofficial Instructions: SH* $9E SHX absolute,Y 0x044A Unofficial Instructions: SH* $BB LAE absolute,Y 0x044B -Unofficial Immediates $0B ANC Immediate 0x0410 -Unofficial Immediates $2B ANC Immediate 0x0411 -Unofficial Immediates $4B ASR Immediate 0x0412 -Unofficial Immediates $6B ARR Immediate 0x0413 -Unofficial Immediates $8B ANE Immediate 0x0414 -Unofficial Immediates $AB LXA Immediate 0x0415 -Unofficial Immediates $CB AXS Immediate 0x0416 -Unofficial Immediates $EB SBC Immediate 0x0417 +Unofficial Immediates $0B ANC immediate 0x0410 +Unofficial Immediates $2B ANC immediate 0x0411 +Unofficial Immediates $4B ASR immediate 0x0412 +Unofficial Immediates $6B ARR immediate 0x0413 +Unofficial Immediates $8B ANE immediate 0x0414 +Unofficial Immediates $AB LXA immediate 0x0415 +Unofficial Immediates $CB AXS immediate 0x0416 +Unofficial Immediates $EB SBC immediate 0x0417 CPU Interrupts Interrupt flag latency 0x0461 CPU Interrupts NMI Overlap BRK 0x0462 CPU Interrupts NMI Overlap IRQ 0x0463 @@ -106,6 +106,8 @@ CPU Behavior 2 Implied Dummy Reads 0x046D CPU Behavior 2 Branch Dummy Reads 0x048B CPU Behavior 2 JSR Edge Cases 0x047C CPU Behavior 2 Internal Data Bus 0x0490 +CPU Behavior 2 DMA Landing on Write 0x0496 +CPU Behavior 2 DMC Reload Timing 0x0497 Power On State PPU Reset Flag 0x03FF Power On State CPU RAM 0x03FF Power On State CPU Registers 0x03FF diff --git a/tests/roms/AccuracyCoin/mirror/AccuracyCoin-mirror.nes b/tests/roms/AccuracyCoin/mirror/AccuracyCoin-mirror.nes index 038fa10aa1a675060bc7495fa5d0518c239aacd2..b1f0a6ed4ba18f8cf8e9e2f237aa71071d05b44d 100644 GIT binary patch delta 19521 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, commit `46199ae4` | +| Upstream | , commit `f5f41dc2` (rebuilt at v3.1.0; `46199ae4` before) | | Upstream licence | MIT (Copyright (c) 2025 Chris Siebert) — the text is at `../../accuracycoin/LICENSE`, and the corpus-wide index is `../../LICENSES.md` | -| Base ROM md5 | `2f9d83104969a5984caf21a77d6746bd` (identical to `tests/roms/accuracycoin/AccuracyCoin.nes`) | -| This ROM md5 | `8162ca0ae099220401e76719e88762fc` | +| Base ROM md5 | `a3635c87ffb1754f58923d070548898b` (identical to `tests/roms/accuracycoin/AccuracyCoin.nes`) | +| This ROM md5 | `bbd842dfa221cdc5a3ea44f8dfcc37b0` | | Built by | `scripts/accuracycoin-build/build_mirror_rom.py` | | Assembler | upstream's own `nesasm.exe` under `wine`, so the output is the author's toolchain rather than an equivalent one | @@ -32,11 +32,11 @@ RustyNES. ```bash git clone https://github.com/100thCoin/AccuracyCoin /tmp/ac-src -git -C /tmp/ac-src checkout 46199ae4 +git -C /tmp/ac-src checkout f5f41dc2 python3 scripts/accuracycoin-build/build_mirror_rom.py /tmp/ac-src \ --out tests/roms/AccuracyCoin/mirror/AccuracyCoin-mirror.nes \ - --expect-upstream-md5 2f9d83104969a5984caf21a77d6746bd \ + --expect-upstream-md5 a3635c87ffb1754f58923d070548898b \ --wine /usr/bin/wine ``` diff --git a/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv b/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv index e32e93964..7467f5bca 100644 --- a/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv +++ b/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv @@ -104,6 +104,12 @@ # offset, 0x10B5, 0x02 -> 0x03 (the `LDX #test` immediate; `LDY #suite` sits at # 0x10AB). # +# v3.1.0: `sprite-eval-misaligned-oam` settles on frame 211 (212 before). The +# misaligned-evaluation fixes make its tests 3, 6 and 7 pass on their own +# path; before, each failed and fell through the ROM's unpopped fail path, +# which ran a different number of cycles. Verdict unchanged (Pass); the +# generator's output differs from the old data in that one field only. +# # REGENERATE WITH THE GENERATOR, AND THE SCHEMA LINE BELOW COMES FROM IT: # # cargo run -p rustynes-test-harness --release --features test-roms \ @@ -147,6 +153,6 @@ sh-shx-9E 9 4 0x044A $9E SHX absolute,Y Pass 27 legacy-unrecorded sh-shy-9C 9 3 0x0449 $9C SHY absolute,X Pass 27 legacy-unrecorded sprite-eval-2002-flag-timing 17 2 0x048D $2002 flag timing Pass 73 legacy-unrecorded sprite-eval-arbitrary-sprite-zero 17 4 0x0458 Arbitrary Sprite zero Fail(1) 29 legacy-unrecorded -sprite-eval-misaligned-oam 17 5 0x045A Misaligned OAM behavior Pass 212 legacy-unrecorded +sprite-eval-misaligned-oam 17 5 0x045A Misaligned OAM behavior Pass 211 legacy-unrecorded sprite-eval-oam-corruption 17 7 0x047B OAM Corruption Pass 111 legacy-unrecorded sprite-zero-hit-behavior 17 1 0x0457 Sprite 0 Hit behavior Fail(1) 29 legacy-unrecorded diff --git a/tests/roms/accuracycoin/AccuracyCoin.nes b/tests/roms/accuracycoin/AccuracyCoin.nes index 8ba95ee6edb2f968687513ad2e1e5039162801f7..26b6ac06191d13a0a3f5c7e24fa50af2a22e92aa 100644 GIT binary patch delta 19526 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 Zeh?P*pgT!oc55H*9cfsvb-KgZ{{s+3JH`M2 delta 19723 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 xo>H `46199ae4` window is two commits and **165 differing ROM @@ -35,9 +36,20 @@ the battery passes; re-extracting `SOURCE_CATALOG.tsv` from the new `AccuracyCoin.asm` reproduces the committed TSV **byte-identically**, since the insertion moves code and not the result-address map. +The `46199ae4` -> `f5f41dc2` window is nine commits: two new tests (`DMA +Landing on Write`, `DMC Reload Timing`), fixes to `DMC Sync`, `Misaligned OAM +Behavior` and `BG Serial In`, a renamed PPU "Data Bus" -> "IO Bus", and +546 bytes saved by new row macros (see `../AccuracyCoin/README.md`). One of +the fixes changed a verdict here. `adacbc23` ("stack fix") makes +`FAIL_MisalignedOAM_Behavior` pop the return address its `JSR` pushed. Before +it, a failure in tests 2-7 of that routine returned INTO the test body, +which ran on to its final `LDA #1` and recorded a pass, so this emulator's +three real failures there (tests 3, 6, 7) read as 144/144. The new ROM +exposed them; v3.1.0 fixes them (`docs/ppu-2c02.md`). + ## What it is -AccuracyCoin is a single-NROM-cartridge battery of **144 NES accuracy +AccuracyCoin is a single-NROM-cartridge battery of **146 NES accuracy tests** (plus 5 print-only `DRAW` tests) spanning CPU, PPU, APU, bus, IRQ, NMI, dummy-read / dummy-write, DMA, and mapper behaviour. It is interactive on real hardware (the user navigates with D-Pad / A / Start), but our harness uses a fixed @@ -45,7 +57,7 @@ button-press script that triggers "run all" and then reads the result addresses out of CPU RAM directly. Current pass rate (measured via the RAM-direct decoder), against a catalog -of 149 rows / 144 assigned tests: **100.00%** (144 of 144). The last two +of 151 rows / 146 assigned tests: **100.00%** (146 of 146, v3.1.0). The last two gaps were both on the `Advanced Sprite Evaluation` page — "Misaligned OAM2 Address" closed in v2.6.17, and "Frozen OAM2 Increment" in v2.6.18 on the rule that a `$2001` mask change during dot N must not act on dot N @@ -57,7 +69,7 @@ See `docs/STATUS.md` for the authoritative breakdown. | Harness file | Purpose | |--------------|---------| | `crates/rustynes-test-harness/src/accuracy_coin.rs` | Drives ROM from power-on; reads pass-rate from RAM via the catalog decoder. | -| `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` | `OnceLock`-lazy 149-entry catalog parsed from the TSV in `../AccuracyCoin/SOURCE_CATALOG.tsv`. | +| `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` | `OnceLock`-lazy 151-entry catalog parsed from the TSV in `../AccuracyCoin/SOURCE_CATALOG.tsv`. | | `crates/rustynes-test-harness/tests/accuracycoin.rs` | The CI gate — prints per-suite breakdown + per-failing-test list. | ## Why two directories? diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 560260fa0..ef6c4bc9c 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1443,9 +1443,9 @@ re-minted: `T-PS-dual-runahead` (ADR 0032), `T-MISTER-SAVESTATE`, | Ticket | What | Backlog | Slot | | --- | --- | --- | --- | | `T-ECOSYSTEM-WATCH` | Every minor: the wgpu/egui pair, the AccuracyCoin upstream diff, rcheevos, the libretro build image, the Android/iOS policy calendar, the Rust pin | ecosystem survey | every minor | -| `T-LIBRETRO-TOOLCHAIN` | Drop the libretro build's Rust 1.96 pin if a branch pipeline on 1.99 passes all 15 jobs (D5). **Dropped in v3.0.1** (pipeline 119614, 15/15); closes when the first `main` pipeline after merge is green | LR-02 | v3.0.1 | -| `T-ACCURACYCOIN-RESYNC-2610` | Re-sync AccuracyCoin to upstream HEAD (two new tests and a "Misaligned OAM Behavior" fix since 2026-09-19) and triage red first (D25) | ecosystem 7 | v3.1.0 | -| `T-EPOCH-FINGERPRINT` | A committed panel of output hashes that fails CI when it moves without an `EMULATION_EPOCH` rise | CI-02 | v3.1.0 | +| `T-LIBRETRO-TOOLCHAIN` | Drop the libretro build's Rust 1.96 pin if a branch pipeline on 1.99 passes all 15 jobs (D5). **Dropped in v3.0.1** (pipeline 119614, 15/15); **CLOSED v3.1.0**: the first `main` pipeline after the merge, 119931, ran 15/15 green | LR-02 | v3.0.1 | +| `T-ACCURACYCOIN-RESYNC-2610` | Re-sync AccuracyCoin to upstream HEAD (two new tests and a "Misaligned OAM Behavior" fix since 2026-09-19) and triage red first (D25). **DONE v3.1.0**: `f5f41dc2`, 146/146; two defects fixed red first (write-refused DMC load DMA, misaligned sprite evaluation, the latter a false pass on the old ROM) | ecosystem 7 | v3.1.0 | +| `T-EPOCH-FINGERPRINT` | A committed panel of output hashes that fails CI when it moves without an `EMULATION_EPOCH` rise. **DONE v3.1.0** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment) | CI-02 | v3.1.0 | | `T-CPU-OVERCLOCK` | The CPU-multiplier overclock, in `HardwareOptions`, movies and netplay (D22) | FE-01 | v3.1.0 | | `T-SPRITE-LIMIT` | "Disable sprite limit", render-only, carried like the overclock (D22) | FE-02 | v3.1.0 | | `T-PAL-EMPHASIS` | The PAL/Dendy emphasis red/green swap | ACC-01 | v3.1.0 | diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index a6ed63983..dfb2bc67d 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -88,4 +88,5 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | # | Outcome | Evidence | | --- | --- | --- | -| — | not started | — | +| 1 | **Done.** Upstream `f5f41dc2`, 151 rows / 146 scored, **146 of 146**. Two defects fixed red first: the DMC load DMA a write refuses (4 cycles, not 3), and misaligned sprite evaluation (seed at dot 65, a four-byte copy from `m = 3`, the X range test). `Misaligned OAM behavior` had been a false pass on the old ROM (its fail path did not pop its return address, upstream `adacbc23`). `EMULATION_EPOCH` 3, BUS section 3. Extractor and `derive_indices.py` read `tblf1`/`tblf2` and fail closed; the mirror ROM is rebuilt. **Sibling consequence (read in `rtl/ppu2c02.sv`, 2026-10-07):** the RTL already seeds evaluation from OAMADDR at the end of the clear and already copies four bytes by a byte counter, but it never applies the X out-of-range `&$FC` (the inverse of the oracle's old defect); the DMC write-refusal path is unread yet. The pin move will move the AccuracyCoin goldens; the RTL needs the X rule (and the DMC rule, if absent), each with a gate that catches its mutant, written from the ROM's comments and nesdev, not from the oracle's Mesen2-derived evaluation region | battery red at errors 9, 3, 6, 7 in turn, each fix green; `misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x` catches 3 of 3 mutants; TriCNES per-cycle diff at the test-9 `STA $5000` | +| 2 | **Done.** `tests/epoch_fingerprint.rs` + `golden/epoch_fingerprint.tsv` (epoch 3, `last_release_epoch` 2): seven panel ROMs, every frame's framebuffer, audio, end RAM, CPU cycles. Fails on moved output while the epoch equals the last release's and refuses that bless; after a rise, a re-bless suffices. ADR 0045 amended; the release cut must set `last_release_epoch` (`docs/agents/ci-and-release.md`) | with the cut simulated: control passes; both of this release's fixes reverted as mutants fail and their bless is refused (table unchanged); epoch 4 without bless fails, with bless passes; all files restored byte for byte | From 64aaf624530084649d4542822e36b96c103fd17c Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 19:12:08 -0400 Subject: [PATCH 02/21] test(epoch): enforce the emulation-epoch rule with an output fingerprint T-EPOCH-FINGERPRINT (v3.1.0 item 2, CI-02). ADR 0045's rule -- raise EMULATION_EPOCH in the same change as anything that alters a frame, a sample or a bus cycle against the last release -- was enforced by hand. Forgetting it ships movies that replay on the new timing and netplay sessions that desync, with nothing saying why. ADR 0045 asked for a check that ties the epoch to a fingerprint of a fixed panel; this is it. MECHANISM tests/epoch_fingerprint.rs runs seven committed test ROMs, one per subsystem (the full AccuracyCoin battery, ny2011, spritecans, dmc_tests latency, mmc3_test_2 4-scanline_timing, sprdma_and_dmc_dma, flowing_palette), and fingerprints each: every frame's framebuffer (FNV of per-frame FNVs, so a transient cannot hide behind a converged last frame), all audio samples, end RAM and the CPU cycle count. golden/epoch_fingerprint.tsv records the rows, the epoch they describe, and `last_release_epoch`, the epoch the last release shipped. - moved output while EMULATION_EPOCH == last_release_epoch: FAIL, and RUSTYNES_BLESS_EPOCH_FINGERPRINT=1 is REFUSED in that state. The refusal is the gate; a bless that accepted moved rows would make the rule optional again. - once the epoch is above last_release_epoch, a re-bless records new output. A second behaviour change in the same release needs a re-bless, not a second rise, because the rule is relative to the last release (a first version demanded a rise per change; corrected before commit). - the table must name the current epoch and have one row per panel ROM. Blessed at epoch 3 / last_release_epoch 2 (this branch already raised the epoch for the AccuracyCoin fixes). SHOWN TO WORK (last_release_epoch set to 3 to simulate the next cut; all four touched files restored byte for byte afterwards): - control: passes - mutant A, the DMC write-refusal latch never set: fails, names the epoch rule; its bless is refused and the table is unchanged - mutant B, the X range test removed: fails - B with EMULATION_EPOCH raised to 4, no bless: fails, asks for a re-bless - B, epoch 4, blessed: passes, and a clean rerun passes Both mutants are this release's own accuracy fixes reverted, so the panel is shown to see the class of change it exists for. Only the AccuracyCoin row moved for either. COVERAGE LIMIT, stated where it lives (module docs, ADR 0045 amendment): a change that moves nothing on these seven ROMs passes. The commercial snapshot suites stay the wider net. It runs in CI's test-roms job (the job runs the whole harness with the feature); about 15 s. RELEASE CEREMONY: the cut must set `# last_release_epoch` to the shipped epoch in the release commit, or the gate stops guarding. Recorded in docs/agents/ci-and-release.md beside the epoch rule. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../golden/epoch_fingerprint.tsv | 15 + .../tests/epoch_fingerprint.rs | 308 ++++++++++++++++++ ...-timing-epoch-guards-movies-and-netplay.md | 16 + docs/agents/ci-and-release.md | 2 +- 4 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 crates/rustynes-test-harness/golden/epoch_fingerprint.tsv create mode 100644 crates/rustynes-test-harness/tests/epoch_fingerprint.rs diff --git a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv new file mode 100644 index 000000000..c5587f46e --- /dev/null +++ b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv @@ -0,0 +1,15 @@ +# T-EPOCH-FINGERPRINT: what the panel in tests/epoch_fingerprint.rs produces +# under the epoch below. Generated; re-bless with +# RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. `last_release_epoch` is the epoch +# the last release shipped, set by hand at each release cut: output may +# move only while EMULATION_EPOCH is above it. +# epoch 3 +# last_release_epoch 2 +# rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles +accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158 +nes-test-roms/ny2011/ny2011.nes 600 118f6b763cc1c58a dda9e74ae2696052 538041de1d7bd79f 17838523 +nes-test-roms/spritecans-2011/spritecans.nes 300 e8caa6ca3d863712 fbdef8039c470bda e53aaa23069253a9 8904372 +nes-test-roms/dmc_tests/latency.nes 240 b112947dcb56f813 2b4cacae717f270b 286ebb8ad39d4642 7117579 +nes-test-roms/mmc3_test_2/rom_singles/4-scanline_timing.nes 120 c1423fdfef9a85fa cceb1ba4374551ca 73baf7b42fa5860e 3543898 +nes-test-roms/sprdma_and_dmc_dma/sprdma_and_dmc_dma.nes 120 bb655ab4bc5277ac 9a8fbf6de707b966 a0779243e8ce2902 3543900 +nes-test-roms/full_palette/flowing_palette.nes 120 72990514f3e3bfb3 8a91bbbb1ead50e8 27337e564377faba 3543884 diff --git a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs new file mode 100644 index 000000000..b8a29c09e --- /dev/null +++ b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs @@ -0,0 +1,308 @@ +//! `T-EPOCH-FINGERPRINT` (v3.1.0, CI-02): the emulation epoch is enforced by +//! a test, not by memory. +//! +//! [`rustynes_core::EMULATION_EPOCH`] tells a movie or a netplay peer which +//! emulator *behaviour* it was made under (ADR 0045). Its rule is "raise it in +//! the same change as anything that alters a frame, a sample or a bus cycle +//! against the last release", and until v3.1.0 that rule was enforced by hand +//! only: a change that moved output and forgot the epoch shipped movies that +//! replay wrongly and netplay sessions that desync, with nothing saying why. +//! +//! This test runs a fixed panel of committed test ROMs and fingerprints what +//! each one produces: every frame's framebuffer, every audio sample, the end +//! RAM and the total CPU cycle count. The fingerprints are committed in +//! `golden/epoch_fingerprint.tsv` together with the epoch they belong to. +//! +//! The table also records `last_release_epoch`, the epoch the last release +//! shipped (set by hand at each release cut, like the release anchors): +//! +//! * Fingerprints match the table: pass (once the table names the current +//! epoch). +//! * A fingerprint moved while `EMULATION_EPOCH` still equals the last +//! release's: **fail**. Raise the epoch (adding its row to the table in +//! `hardware_options.rs`), then re-bless. +//! * A fingerprint moved after the epoch was already raised this release: fail +//! until re-blessed. A second behaviour change in one release needs a +//! re-bless, not a second rise, because the rule is relative to the last +//! release (ADR 0045). +//! +//! Re-bless with `RUSTYNES_BLESS_EPOCH_FINGERPRINT=1`. **Blessing refuses to +//! write moved fingerprints while the epoch equals the last release's** -- +//! that refusal is the gate; a bless that accepted them would make the rule +//! optional again. +//! +//! The panel is chosen for reach, one subsystem per ROM, so a behaviour change +//! anywhere in the core is likely to move at least one fingerprint. It is not +//! a proof that every change is caught: a change that alters no output on +//! these seven ROMs passes, which is also when the epoch rule does not demand +//! a rise for them. The commercial snapshot suites (`external_*`) remain the +//! wider net, and the release checklist still names the rule. + +#![cfg(feature = "test-roms")] + +mod common; + +use std::fmt::Write as _; +use std::fs; +use std::path::PathBuf; + +use common::{fnv1a64, rom_path}; +use rustynes_core::{Buttons, EMULATION_EPOCH, Nes}; + +/// One panel entry: a ROM, how long to run it, and whether to press START +/// after the boot frames (`AccuracyCoin`'s "run every test" menu entry). +struct Probe { + rom: &'static str, + frames: u32, + press_start: bool, + /// What it is there to catch, for the failure message. + reach: &'static str, +} + +const PANEL: &[Probe] = &[ + Probe { + rom: "accuracycoin/AccuracyCoin.nes", + frames: 3200, + press_start: true, + reach: "the whole AccuracyCoin battery: CPU, PPU, APU, DMA, bus", + }, + Probe { + rom: "nes-test-roms/ny2011/ny2011.nes", + frames: 600, + press_start: false, + reach: "music and sprites", + }, + Probe { + rom: "nes-test-roms/spritecans-2011/spritecans.nes", + frames: 300, + press_start: false, + reach: "sprite evaluation and overflow", + }, + Probe { + rom: "nes-test-roms/dmc_tests/latency.nes", + frames: 240, + press_start: false, + reach: "DMC fetch latency (audio)", + }, + Probe { + rom: "nes-test-roms/mmc3_test_2/rom_singles/4-scanline_timing.nes", + frames: 120, + press_start: false, + reach: "MMC3 IRQ timing", + }, + Probe { + rom: "nes-test-roms/sprdma_and_dmc_dma/sprdma_and_dmc_dma.nes", + frames: 120, + press_start: false, + reach: "OAM and DMC DMA overlap", + }, + Probe { + rom: "nes-test-roms/full_palette/flowing_palette.nes", + frames: 120, + press_start: false, + reach: "palette and emphasis output", + }, +]; + +/// Frames to let a ROM boot before `AccuracyCoin`'s START press. +const BOOT_FRAMES: u32 = 300; +/// Frames START is held. +const START_FRAMES: u32 = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Fingerprint { + rom: String, + frames: u32, + framebuffer: u64, + audio: u64, + ram: u64, + cycles: u64, +} + +impl Fingerprint { + fn row(&self) -> String { + format!( + "{}\t{}\t{:016x}\t{:016x}\t{:016x}\t{}", + self.rom, self.frames, self.framebuffer, self.audio, self.ram, self.cycles + ) + } + + fn parse(line: &str) -> Option { + let c: Vec<&str> = line.split('\t').collect(); + if c.len() != 6 { + return None; + } + Some(Self { + rom: c[0].to_string(), + frames: c[1].parse().ok()?, + framebuffer: u64::from_str_radix(c[2], 16).ok()?, + audio: u64::from_str_radix(c[3], 16).ok()?, + ram: u64::from_str_radix(c[4], 16).ok()?, + cycles: c[5].parse().ok()?, + }) + } +} + +/// Run one probe and fingerprint it. Every frame's framebuffer is folded in, +/// not only the last, so a transient difference cannot hide behind a +/// converged final frame. +fn measure(p: &Probe) -> Fingerprint { + let path = rom_path(p.rom); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let mut nes = Nes::from_rom(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", p.rom)); + let mut frame_hashes: Vec = Vec::with_capacity(p.frames as usize * 8); + let mut audio: Vec = Vec::new(); + let mut step = |nes: &mut Nes| { + nes.run_frame(); + frame_hashes.extend_from_slice(&fnv1a64(nes.framebuffer()).to_le_bytes()); + for s in nes.drain_audio() { + audio.extend_from_slice(&s.to_le_bytes()); + } + }; + let run = if p.press_start { + for _ in 0..BOOT_FRAMES { + step(&mut nes); + } + nes.set_buttons(0, Buttons::START); + for _ in 0..START_FRAMES { + step(&mut nes); + } + nes.set_buttons(0, Buttons::empty()); + BOOT_FRAMES + START_FRAMES + } else { + 0 + }; + for _ in run..p.frames { + step(&mut nes); + } + Fingerprint { + rom: p.rom.to_string(), + frames: p.frames, + framebuffer: fnv1a64(&frame_hashes), + audio: fnv1a64(&audio), + ram: fnv1a64(nes.bus().ram_bytes()), + cycles: nes.cycle(), + } +} + +fn table_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("golden/epoch_fingerprint.tsv") +} + +/// The committed table: the epoch its rows describe, the epoch of the last +/// release, and the rows. +struct Table { + epoch: u32, + last_release_epoch: u32, + rows: Vec, +} + +fn read_table() -> Option { + let text = fs::read_to_string(table_path()).ok()?; + let (mut epoch, mut last) = (None, None); + let mut rows = Vec::new(); + for line in text.lines() { + if let Some(e) = line.strip_prefix("# epoch\t") { + epoch = e.trim().parse().ok(); + } else if let Some(e) = line.strip_prefix("# last_release_epoch\t") { + last = e.trim().parse().ok(); + } else if !line.starts_with('#') && !line.trim().is_empty() { + rows.push( + Fingerprint::parse(line) + .unwrap_or_else(|| panic!("malformed epoch_fingerprint.tsv row: {line:?}")), + ); + } + } + Some(Table { + epoch: epoch.expect("epoch_fingerprint.tsv has no `# epoch` line"), + last_release_epoch: last.expect("epoch_fingerprint.tsv has no `# last_release_epoch` line"), + rows, + }) +} + +fn render_table(last_release_epoch: u32, rows: &[Fingerprint]) -> String { + let mut out = String::from( + "# T-EPOCH-FINGERPRINT: what the panel in tests/epoch_fingerprint.rs produces\n\ + # under the epoch below. Generated; re-bless with\n\ + # RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. `last_release_epoch` is the epoch\n\ + # the last release shipped, set by hand at each release cut: output may\n\ + # move only while EMULATION_EPOCH is above it.\n", + ); + let _ = writeln!(out, "# epoch\t{EMULATION_EPOCH}"); + let _ = writeln!(out, "# last_release_epoch\t{last_release_epoch}"); + out.push_str("# rom\tframes\tframebuffer_fnv\taudio_fnv\tram_fnv\tcpu_cycles\n"); + for r in rows { + out.push_str(&r.row()); + out.push('\n'); + } + out +} + +#[test] +fn output_moves_only_with_the_emulation_epoch() { + let now: Vec = PANEL.iter().map(measure).collect(); + let bless = std::env::var_os("RUSTYNES_BLESS_EPOCH_FINGERPRINT").is_some(); + let table = read_table().expect( + "golden/epoch_fingerprint.tsv is missing or unreadable; it needs a \ + `# last_release_epoch` line written by hand before the first bless", + ); + assert!( + table.last_release_epoch <= EMULATION_EPOCH, + "last_release_epoch {} is above EMULATION_EPOCH {EMULATION_EPOCH}", + table.last_release_epoch + ); + + let moved: Vec = now + .iter() + .zip(PANEL) + .filter(|(f, _)| !table.rows.contains(f)) + .map(|(f, p)| format!(" {} ({}): now {}", f.rom, p.reach, f.row())) + .collect(); + // The rule (ADR 0045): output may differ from the LAST RELEASE only under + // a raised epoch. Within one release, a second change after the rise + // needs a re-bless, not a second rise. + let raised = EMULATION_EPOCH > table.last_release_epoch; + + assert!( + moved.is_empty() || raised, + "emulated output changed but EMULATION_EPOCH is still {EMULATION_EPOCH}, \ + the epoch the last release shipped.\n\ + Raise it in crates/rustynes-core/src/hardware_options.rs (with a row \ + in its table saying what moved), then re-bless this table with \ + RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. Moved:\n{}", + moved.join("\n") + ); + + if bless { + fs::write(table_path(), render_table(table.last_release_epoch, &now)) + .expect("write epoch_fingerprint.tsv"); + eprintln!( + "blessed epoch_fingerprint.tsv at epoch {EMULATION_EPOCH} ({} rows moved)", + moved.len() + ); + return; + } + + assert!( + moved.is_empty(), + "emulated output changed under epoch {EMULATION_EPOCH}, which is already \ + raised above the last release's {}; re-bless with \ + RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. Moved:\n{}", + table.last_release_epoch, + moved.join("\n") + ); + assert_eq!( + table.epoch, EMULATION_EPOCH, + "EMULATION_EPOCH is {EMULATION_EPOCH} but the fingerprint table records {}; \ + re-bless with RUSTYNES_BLESS_EPOCH_FINGERPRINT=1 so the table names the \ + epoch its hashes belong to", + table.epoch + ); + assert_eq!( + table.rows.len(), + PANEL.len(), + "the table has {} rows for a panel of {}; re-bless after changing the panel", + table.rows.len(), + PANEL.len() + ); +} diff --git a/docs/adr/0045-a-core-timing-epoch-guards-movies-and-netplay.md b/docs/adr/0045-a-core-timing-epoch-guards-movies-and-netplay.md index 97e4b4d53..ca187c6a3 100644 --- a/docs/adr/0045-a-core-timing-epoch-guards-movies-and-netplay.md +++ b/docs/adr/0045-a-core-timing-epoch-guards-movies-and-netplay.md @@ -90,6 +90,22 @@ entirely, because the bytes do not change. would enforce the rule. If such a check proves practical, it is added as part of this decision's implementation; its coverage limit (the panel is not every game) is stated where it lives. + + **Amendment, 2026-10-07 (v3.1.0, `T-EPOCH-FINGERPRINT`): the check exists.** + `crates/rustynes-test-harness/tests/epoch_fingerprint.rs` fingerprints + seven committed test ROMs (every frame's framebuffer, all audio, end RAM + and the CPU cycle count) against `golden/epoch_fingerprint.tsv`, which + records the epoch and `last_release_epoch`, the epoch the last release + shipped. Output that moves while `EMULATION_EPOCH` equals + `last_release_epoch` fails, and a re-bless is refused in that state; once + the epoch is raised, a re-bless records the new output, and a second + change in the same release needs only another re-bless. It runs in CI's + `test-roms` job. Shown on two seeded mutants (this release's own two + accuracy fixes, reverted): each fails, its bless is refused, and raising + the epoch then re-blessing passes. **Coverage limit:** a change that moves + nothing on the panel passes; the commercial snapshot suites stay the wider + net. **The release cut must set `last_release_epoch`** to the shipped epoch + (`docs/agents/ci-and-release.md`), or the gate stops guarding. - **Libretro is unaffected.** RetroArch netplay compares serialized states, which already differ between core versions. diff --git a/docs/agents/ci-and-release.md b/docs/agents/ci-and-release.md index e82c5a75e..d28d8adb0 100644 --- a/docs/agents/ci-and-release.md +++ b/docs/agents/ci-and-release.md @@ -35,7 +35,7 @@ - **The standard is zero warnings, verified by reading the logs, not the check marks.** A green run hid: rustup's auto-install deprecation, a cargo-ndk NDK-path mismatch, UniFFI's ktlint warning, two Kotlin warnings in generated bindings, 13 MkDocs dead links, a Node DEP0040 inside `deploy-pages`, a Homebrew tap-trust warning on iOS, and criterion's "Unable to complete N samples" on every bench. The sweep that found them: annotations from `repos/{o}/{r}/check-runs/{job}/annotations` for every job of the last 150 runs, plus `gh run view --log` of the latest run of each workflow grepped for `warning|error|deprecat`, minus lines that merely echo a script's own `::error::` text. Suppress only what is in third-party code, by the narrowest switch (`--disable-warning=DEP0040` on one step), and say where it goes away. - **`gh pr ready` seconds after a push loses the ready run (2026-09-23, #548).** The push starts a `synchronize` run whose payload still says `draft: true`, so its heavy jobs skip; `gh pr ready` starts a `ready_for_review` run beside it, and the concurrency group cancelled the READY one. What was left was a green `CI success` that had run no `test`, no `test-roms`, no Android and no Pages build. The gate script caught it only because it listed every job's conclusion instead of reading the rollup. **Push, wait until that push's runs have started, then `gh pr ready`.** If the ready-mode runs show `cancelled`, `gh run rerun ` re-runs them with their original `ready_for_review` payload. That is how #548's ready run was recovered: every job passed on attempt 2. - **A pull request tests Linux only unless its branch is `release/*` (2026-10-03, v2.9.8).** `select matrix` in `ci.yml` gives an ordinary PR `ubuntu-26.04` and `ubuntu-26.04-arm`; macOS and Windows test legs run only on `main`, `release/*` PRs, dispatch and the weekly cron. v2.9.8's release PR was `feat/v2.9.8-cadence`: 36 checks green, merged, then `main` failed on `test (windows-latest)` alone, so Auto Release skipped and the release had no tag. The cause was the checkout, not the code: CRLF on the Windows runner stopped the source-shape tests' split on `"\n#[cfg(test)]\nmod tests {"`, so `every_load_path_installs_a_dual_system_cabinet` counted its own strings (3 against 1). Fixed by `*.rs text eol=lf` in `.gitattributes` (#582, itself on a `release/*` branch so its PR ran Windows). **Name every release PR's branch `release/*`.** -- **A release whose goldens or commercial snapshots moved must raise `rustynes_core::EMULATION_EPOCH` (v3.0.0, ADR 0045).** The epoch is what tells a movie or a netplay peer which emulator *behaviour* it expects. `.rnm` format 5 records it, protocol 6 sends it, and a mismatch is refused, naming both epochs. Forget the bump and a movie recorded on the old behaviour replays its inputs on the new timing, and a mixed-version session desyncs, both silently: the exact failure the epoch was added to prevent. **The trigger is mechanical:** any re-blessed golden, moved `.snap`, or changed AccuracyCoin/blargg/commercial result between the last release and this one. It does not move for byte-identical refactors or performance work, frontend features, or new mapper families (no earlier output exists to differ from). Check it at the release cut, beside the CHANGELOG. +- **A release whose goldens or commercial snapshots moved must raise `rustynes_core::EMULATION_EPOCH` (v3.0.0, ADR 0045).** The epoch is what tells a movie or a netplay peer which emulator *behaviour* it expects. `.rnm` format 5 records it, protocol 6 sends it, and a mismatch is refused, naming both epochs. Forget the bump and a movie recorded on the old behaviour replays its inputs on the new timing, and a mixed-version session desyncs, both silently: the exact failure the epoch was added to prevent. **The trigger is mechanical:** any re-blessed golden, moved `.snap`, or changed AccuracyCoin/blargg/commercial result between the last release and this one. It does not move for byte-identical refactors or performance work, frontend features, or new mapper families (no earlier output exists to differ from). Check it at the release cut, beside the CHANGELOG. **Since v3.1.0 a test enforces it** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment): a moved panel fingerprint fails unless the epoch is above the table's `last_release_epoch`. **At every release cut, set `# last_release_epoch` in `crates/rustynes-test-harness/golden/epoch_fingerprint.tsv` to the epoch being shipped**, in the release commit; skip it and the next release can move output under an unchanged epoch without the gate noticing. - **A runner outage made `CI success` green with nothing tested (2026-10-05, the #585 merge, run 37373901726).** No GitHub-hosted runner ever acquired `detect code changes` ("The job was not acquired by Runner of type hosted even after multiple attempts": no runner name, zero steps, cancelled after 15 minutes), so every gate downstream was skipped. The API reported the job `cancelled`, yet the aggregate's `contains(needs.*.result, 'cancelled')` step was itself skipped, so `needs` did not carry that value. `CI success`, `main`'s only required check, then reported success. **Fixed in v3.0.0** with a step that requires `changes` to have succeeded, and `setup` to have succeeded whenever `changes` found code. A docs-only change still passes, because `setup` skips exactly when `code` is false. The CodeQL "Push on main" run lost `Analyze (rust)` to the same outage. **A default-setup CodeQL run cannot be re-run**: the run and job retry endpoints answer 403, and re-applying the default-setup configuration returns the last run's id rather than starting one. The next push to `main` (or the weekly schedule) is what re-analyses. - **HOLD THE AUTO RELEASE BY CANCELLING MAIN'S CI RUN (v3.0.0).** `release-auto.yml` fires on `workflow_run: CI completed` on `main`, requires `conclusion == 'success'`, and tags that run's `head_sha`. When the maintainer asked for a full docs sweep after #586 had merged, `gh run cancel` on the main CI run of the merge commit left it `cancelled`, so no tag or release was made. The sweep then goes in as its own PR, and that PR's green main run is what tags v3.0.0, on the swept commit. Confirm with `gh release view vX` and `git ls-remote --tags origin vX`. The cancelled run's own `CI success` shows red, which is the outage gate working, not a failure. - **THE FIRST MAJOR BUMP FOUND TWO `bump_release.py` BLIND SPOTS (v3.0.0).** (1) Internal path dependencies carried `version = "2.0.0"`, a caret range no 3.x satisfies, so after the bump `cargo update -w` failed to resolve the workspace. No minor or patch bump can see it. `bump_internal_requirements` now moves them on every MAJOR, and run `cargo update -w` in BOTH workspaces (`crates/rustynes-cosim` has its own lock) after any bump. (2) A CHAIN anchor with its own description (`vX "C" released — . Built on ...`, root `ROADMAP.md`) kept the OLD release's description under the new name; CodeRabbit caught "v3.0.0 ... the tenth release of the v2.9.x line". The selftest had that exact shape and asserted only where the insertion landed. A described head now takes `--lead`, and the old text moves into the chain beside its own release. From 31f1d05ae0d04119175529607fe3d92331887adc Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 19:12:20 -0400 Subject: [PATCH 03/21] docs: record the palette A/B result and two v3.0.1 tooling traps Records carried from the v3.0.1 cycle into v3.1.0. - docs/performance.md: the palette-offset lead closes as measured. 8f449691 -> 33ea0572 alone, ab_check.sh, two independent runs on a quiet host (order-bias drift <= 0.95%): nestest +1.5/+1.8%, palette +0.4/+1.9%, nestest_fast +0.5/+1.1%, palette_fast +1.4/+0.5%. Slower on every workload in both runs, so 33ea0572 is confirmed as a contributor, but at 0.4-1.9%, not the 4-5% the bisection step showed, and the runs disagree on which workload pays most. The commit adds nothing to the frame loop; code layout or inlining is the untested working explanation. Nothing adopted. Logs are in the gitignored salvaged/perf-v3.0.1-palette-ab/. - docs/agents/tooling-traps.md: `until ! pgrep -f ` never exits, because the loop shell's own command line contains the pattern. Three wait loops outlived their jobs this way in v3.0.1. Wait on a PID, a file the job writes last, or `pgrep -f '[p]attern'`. - docs/agents/review-bots.md: a CodeRabbit review slice is red in CI by construction when a feature-gated test depends on the other slice (#591: holy_mapperel.rs and the provenance audit). Say so on the slice PR and judge CI on the release PR only. No code changes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- docs/agents/review-bots.md | 2 +- docs/agents/tooling-traps.md | 1 + docs/performance.md | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/agents/review-bots.md b/docs/agents/review-bots.md index 2e8ea5ec8..013e4061c 100644 --- a/docs/agents/review-bots.md +++ b/docs/agents/review-bots.md @@ -32,5 +32,5 @@ - **AGY RE-FILES REFUTED FINDINGS EVERY ROUND, SO THE CEREMONY NEEDS A STOPPING RULE (2026-09-23, #545 / sibling #30 / #31).** Four rounds on three docs PRs. By round 4 the Antigravity reviewer was re-raising, sometimes as **BLOCKING**, claims already refuted with line numbers in rounds 1–3: "`$G` may be undefined" (`tb/regress.sh:28` defines it), "`$VERIFY` is unbound under `set -u`" (`tb/fetch-goldens.sh:123` defaults it, three times), and "TSV `#` lines crash the loop" (`:240` skips them). It re-reviews on every push and does not carry forward the rebuttals, so every fix push buys another round with the same claims in it. **Rule:** fix what is real, reply to everything with evidence, and when a round brings only repeats and nits, reply to them and merge on green rather than pushing again. Every push restarts CI (the `test-roms` job alone is tens of minutes) *and* the reviewer. The rounds still paid: they found an ADR consequence describing a rename that did not happen, a `BLARGG_DIR` default ignoring `GOLDEN_DIR` (and the same bug in `mutate_apu.sh`), and a sentence ("measured on the console") that read as a hardware claim. Declining is only safe after checking each claim, because the first "blocking" finding of a round is not always the repeat it looks like. - **COPILOT REVIEWS ONCE PER PR HERE, AND DOCS7 FLAGS THE PRIVATE SIBLING AS DEAD (2026-09-23).** On all three PRs Copilot posted a single review, on the first push, and never re-reviewed later pushes. So the merge gate can only check that it *has* posted, not that it reviewed the head; agy's comment timestamp is the per-push signal. Context7's **Docs7** bot (a `context7[bot]` issue comment plus a NEUTRAL check) link-checks changed files as an **anonymous** client. Every `https://github.com/doublegate/RustyNES_MiSTer/...` link is therefore reported dead, because that repository is **private** (`gh repo view --json visibility` returns `PRIVATE`). Those are correct links, not findings. Its other catch was real: two pre-existing links into `to-dos/` directories that had moved to `to-dos/archive/`. -- **CODERABBIT SKIPS A PR OVER 100 FILES; REVIEW IT AS SLICES, AND EXPECT CROSS-SLICE FALSE FINDINGS (v2.9.8, v3.0.0).** "Review skipped: 163 files exceed the limit of 100." Both MAJOR release PRs were reviewed as review-only slice PRs (`review/vX-{a,b}` from `main`, one path partition each, built with `git diff --binary -- | git apply --index`, and proved equal to the head on their paths with `git diff --quiet -- `). A slice does not build alone, so it is committed with `--no-verify` and says so. Keep the slices in sync on every release-branch push, and close them unmerged. The trap: a slice lacks the other half's changes. On #587 CodeRabbit rated Major that `rustynes-core` still built the now-`#[non_exhaustive]` `Cartridge` by literal; that change was in the other slice. Shown the release head's lines, it withdrew. It is also rate-limited hourly on this plan, so schedule the re-request rather than re-posting. +- **CODERABBIT SKIPS A PR OVER 100 FILES; REVIEW IT AS SLICES, AND EXPECT CROSS-SLICE FALSE FINDINGS (v2.9.8, v3.0.0).** "Review skipped: 163 files exceed the limit of 100." Both MAJOR release PRs were reviewed as review-only slice PRs (`review/vX-{a,b}` from `main`, one path partition each, built with `git diff --binary -- | git apply --index`, and proved equal to the head on their paths with `git diff --quiet -- `). A slice does not build alone, so it is committed with `--no-verify` and says so. Keep the slices in sync on every release-branch push, and close them unmerged. The trap: a slice lacks the other half's changes. On #587 CodeRabbit rated Major that `rustynes-core` still built the now-`#[non_exhaustive]` `Cartridge` by literal; that change was in the other slice. Shown the release head's lines, it withdrew. It is also rate-limited hourly on this plan, so schedule the re-request rather than re-posting. **A slice is also RED in CI by construction (v3.0.1, #591)**: feature-gated tests that live in one slice and depend on the other (`holy_mapperel.rs`, the provenance audit reading files the other slice changes) fail there and nowhere else. Say so on the slice PR at once, with the named tests, and judge CI on the release PR only. - **AGY'S BLOCKING FINDINGS CAN CITE CODE THAT DOES NOT EXIST (v3.0.0, #586).** One round filed a BLOCKING "state desync in `SpectatorSession::pop_frame`... `push_frame` relies on `relative_idx`"; none of the three exists in the spectator (`git grep` found only the movie attestation builder's `push_frame`). The same round cited `crates/rustynes-core/src/netplay.rs` and `scripts/bump_release.py`, neither of which exists. Another round called `let _ = socket.send_to(..)` a silent failure, against the module's documented contract and four sibling sends. agy edits ONE comment in place, so these sat in the folded "Earlier review rounds" block. `git grep` every cited symbol and `ls` every cited path before acting; refute with the command and the line numbers. diff --git a/docs/agents/tooling-traps.md b/docs/agents/tooling-traps.md index cd6a2c781..e67cd3a4a 100644 --- a/docs/agents/tooling-traps.md +++ b/docs/agents/tooling-traps.md @@ -31,4 +31,5 @@ - **`set -- $var` DOES NOT WORD-SPLIT IN zsh EITHER (v2.9.8).** Same root cause as the `for x in $var` entry above: a loop `for rb in "repo branch" ...; do set -- $rb; gh api -X DELETE .../$1/.../$2` passed the whole string as `$1` and got three HTTP 404s. Call the command once per item with literal arguments. - **A 1PASSWORD OUTAGE BLOCKS CHERRY-PICKS, NOT ONLY COMMITS (v2.9.8).** Integrating an agent's branch re-signs each picked commit, so the whole integration stops until 1Password is back. Nothing is lost: the agent's commits stay on its branch, and staged work stays staged. Ask the maintainer to unlock it; never pass `--no-gpg-sign`. - **PARALLEL WORKTREE AGENTS NEED THE TIP'S MOVES TOLD TO THEM (v2.9.8).** Five agents worked from one base while their siblings' commits landed. Each time the branch moved (a rename, a removed API), the still-running agents were sent the new tip and the breaking facts and told to rebase before their final commit. Integration then needed only CHANGELOG and ADR conflict resolution. To relieve a loaded host, an agent can be paused at a safe point: let its current cargo job finish, write a status file, end its turn, and resume it later by message. +- **`pgrep -f ` IN A WAIT LOOP MATCHES THE LOOP ITSELF (v3.0.1).** `until ! pgrep -f quartus_fit; do sleep 30; done` never ended: the pattern appears in the loop shell's own command line, so `pgrep` always finds one process. Three background loops stayed alive this way after the jobs they waited for had finished, and had to be stopped with TaskStop. Wait on a PID (`while kill -0 $pid`), on a file the job writes last, or use the bracket trick (`pgrep -f '[q]uartus_fit'`). The same mechanism is why `pkill -f` killed the tool shell (memory, and the trap below). - **A WORKTREE-ISOLATED AGENT CANNOT RUN GIT HERE, AND ITS WORKTREE STARTS FROM `main` (v3.0.1).** Two forks were given `isolation: worktree` on 2026-10-07. Both worktrees were created from `main` (`9c23715b`), not from the release branch the session was on. The first agent could still run git and reset its own clean worktree to the right commit. The second could not run git at all: the RTK hook rewrites every `git` into `rtk git`, and the worktree-isolation guard refuses a command that "runs rtk with a git command among its operands" (plain `git`, `git -C`, `command git` and `rtk git` were all refused). It made no change, and the harness removed the unchanged worktree itself. For documents-only work, run the fork in the main tree instead, stage by explicit path, and make no commits from the session until it reports. Otherwise, check the agent's first `git log` before trusting anything it writes. diff --git a/docs/performance.md b/docs/performance.md index acaa9ce91..35f980140 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1212,6 +1212,25 @@ is named as the suspect, not established: one more step, `8f449691` → `33ea0572` alone, would settle it. **Found and localized; not fixed in v2.9.9.** Nothing here is claimed as a speed-up beyond NL-15. +**That step, measured (v3.0.1 cycle, recorded v3.1.0).** `8f449691` → +`33ea0572` alone, `scripts/perf/ab_check.sh`, two independent runs on a quiet +host, A/B/A order-bias drift at most 0.95%: + +| workload | run 1 | run 2 | +| --- | --- | --- | +| `nestest` | +1.5% | +1.8% | +| `palette` | +0.4% | +1.9% | +| `nestest_fast` | +0.5% | +1.1% | +| `palette_fast` | +1.4% | +0.5% | + +Slower on all four workloads in both runs, so the direction is established +and `33ea0572` is confirmed as a contributor. It is **not** the 4-5% the +bisection step showed: its size is 0.4-1.9%, and the runs disagree about which +workload pays most. The commit adds a module and accessors but nothing to the +frame loop, which leaves code layout or inlining as the working explanation, +untested. No change is adopted; the lead is closed as measured. Logs: +`salvaged/perf-v3.0.1-palette-ab/` (gitignored). + ### v2.9.8 — pacing coverage: 60 Hz, Fifo, and run-ahead (the configurations v2.9.3 left unmeasured) **Finding: presents are even in every configuration measured; run-ahead From 687a5a8acb12b9da0d17424df534dfff25ff0165 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 20:21:12 -0400 Subject: [PATCH 04/21] feat(options): CPU overclock, a working sprite-limit option, format 6 v3.1.0 items 3-5 (T-CPU-OVERCLOCK, T-SPRITE-LIMIT, D22): two options carried in HardwareOptions, so movies record them and netplay peers must match, with one movie-format and one protocol bump for both. Plus a defect the purity work exposed: the debugger's and HD-pack's CHR peek changed the game on five boards. CPU OVERCLOCK (Nes::set_cpu_overclock, 1..=4, default 1) The bus divides the region's master-clock CPU divider (NTSC 12 -> 6/4/3; PAL and Dendy round down, PAL x3 = 5 = x3.2). Everything that measures console time stays at the STOCK rate: the APU and DMC, the mappers' notify_cpu_cycle IRQ counters (VRC / FME-7 / N163 raster splits), the PPU's open-bus decay and post-reset timers. Mechanism: each CPU cycle adds the effective divider to `overclock_debt`; a "stock step" runs those devices once whenever the debt reaches the stock divider, and the APU is handed its own counter (`apu_cycle`) because it derives its put/get phase from the counter it gets. DMA follows that phase, so a DMA takes ~k times the CPU cycles and the same real time. run_frame's 150,000-cycle budget scales by k (x4 plus the 80-line scanline overclock needs ~155,000). The smallest divider, 3, keeps both cycle splits valid: read (0, 3), write (2, 1). At x1 the branch is never taken: every cycle is a stock step and the APU gets the CPU counter, byte for byte the old path; the epoch gate, whose seven ROMs all run at x1, passes unchanged. debt + apu_cycle are in BUS section 3 (unreleased; run-ahead restores mid-run). Desktop: Settings > Enhancements combo; unlike the extra-scanline overclock (held at stock while a movie records and under netplay since v2.9.7) it is recorded as set and replayed with it. NOT done: libretro and mobile options (neither exposes the scanline overclock either). Not hardware behaviour, and said so in the docs. SPRITE LIMIT (Nes::set_sprite_limit_disabled; the desktop checkbox existed since v1.x and was inert) Render-only. After the eighth REAL sprite fetch (slot 7, dot 316) of a visible line, `fetch_extra_sprites` walks OAM from entry 0, skips the first eight in range, and fetches up to 56 more (MAX_EXTRA_SPRITES) through ppu_read_sprite with NO observe_a12_addr; `emit_pixel` draws them only where none of the eight hardware sprites is opaque (higher OAM index = lower priority), never as sprite 0. Evaluation, secondary OAM, overflow, sprite-0 hit and the real fetches are untouched. The pending extras are PPU snapshot v13 (a snapshot can fall between the fetch and the line). The extra reads are made only where `Mapper::chr_reads_are_pure()` (new, default true). A scan of every ppu_read / ppu_read_sprite body for writes to `self` (brace-matched, helper calls followed) found five impure families: MMC2 (9) / MMC4 (10) CHR latches on tiles $FD/$FE, the J.Y. ASIC (35, 90, 209, 211) clocking its IRQ on PPU reads, Bandai 96's address-following inner bank, Nanjing 163's A13 latch. `every_board_that_claims_pure_chr_reads_has_them` reads all of CHR on every constructible id (iNES 0-255, NES 2.0 256-4095) that claims purity and requires save_state unchanged, and pins the impure set; MMC2 or the J.Y. ASIC claiming purity fails it (both shown). FOUND ON THE WAY (fixed): `SystemBus::debug_peek_ppu`, documented as side-effect free and used per frame by the HD-pack compositor (desktop, emu thread, mobile) and by the pattern viewer / hex viewer, read CHR through `mapper.ppu_read`. On MMC2 / MMC4 a full pattern-table read crosses tiles $FD/$FE and flipped the latch: opening the debugger on Punch-Out!! changed the game. It now brackets the read with the board's save_state / load_state on the five impure boards (free elsewhere). Red first: `debug_peek_ppu_changes_no_board` failed on mapper 9, passes now. The existing CHR-RAM sweep had documented the latch flip and worked around it. FORMATS (ADR 0044 amended) - .rnm format 6, minimum 6: the options record gains both fields; a format-5 record is shorter and would decode as garbage, so it is refused (every v5 movie is epoch 1 or 2, already refused). - netplay protocol 7, magic "RNE7", protocol 6's 72-byte Sync unchanged. The configuration hash's input changed, so a v3.0.x peer with identical settings would hash differently and be refused as "settings differ", the wrong reason. "RNE6" joins OLDER_SYNC_MAGICS and is refused as another emulator version, naming its epoch (protocol 6 carries it). TESTS (all mutation-checked; each mutant reverted) - cpu_overclock.rs 6/6. Mutants: every cycle a stock step (2 fail); the restored apu_cycle parity flipped and the debt dropped (snapshot test fails). That test was first BLIND: ny2011 is silent for its first 300 frames (measured peak 0.000), so the APU phase was unobservable; moved to apu_mixer/square.nes and it now asserts the window is audible. - sprite_limit.rs 5/5: with the option on, RAM, audio and CPU cycles match stock frame by frame; blargg sprite_overflow 1-5 pass; carriage; a mid-frame snapshot with 8 extras pending. The stimulus is a ROM BUILT IN THE TEST (16 opaque sprites on scanline 101): spritecans never exceeds 7 per line (measured over 2,400 frames) and NEStress's 62 are transparent (54 extras WERE fetched there, which separated "blind" from "broken"). Mutants: extras never drawn, extras dropped on restore -- both caught. - message.rs: a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch; movie.rs: a_format_5_movie_is_refused_as_too_old. - an_unknown_expansion_device_tag_is_refused located its bytes by a fixed offset from the BUS section's end that went stale in 41367fdb (the latch byte) and still passed by accident: the shifted window held [0, 0] and the byte it corrupted was refused for its own reason. Its window assertion caught it once 9 more bytes landed; the offset is now the real tail. - snapshot_schema_audit: cpu_overclock / cpu_div_effective (config, derived), stock_step (intra-cycle transient) and sprite_limit_disabled (config) classified with reasons; the state they drive is serialized. GATES: fmt; clippy workspace, harness+test-roms, frontend scripting / scripting,hd-pack / retroachievements / full, both wasm builds; rustdoc -D warnings; no_std; --features test-roms --release 3,251 passed / 0 failed / 11 ignored; default workspace 2,891 / 0 / 7; the epoch gate unchanged (x1 and option-off output identical). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 23 +- crates/rustynes-core/src/bus.rs | 164 +++++++++++++- crates/rustynes-core/src/bus_snapshot.rs | 43 +++- crates/rustynes-core/src/hardware_options.rs | 42 +++- crates/rustynes-core/src/lib.rs | 2 +- crates/rustynes-core/src/movie.rs | 47 +++- crates/rustynes-core/src/nes.rs | 109 ++++++++- crates/rustynes-frontend/src/app.rs | 13 +- crates/rustynes-frontend/src/config.rs | 18 +- .../src/debugger/settings_panel.rs | 41 +++- crates/rustynes-frontend/src/emu.rs | 28 +++ crates/rustynes-frontend/src/i18n.rs | 5 +- crates/rustynes-mappers/src/m009_mmc2.rs | 6 + crates/rustynes-mappers/src/m010_mmc4.rs | 6 + crates/rustynes-mappers/src/m035_jy_asic.rs | 6 + crates/rustynes-mappers/src/m096_bandai96.rs | 6 + crates/rustynes-mappers/src/m163_nanjing.rs | 6 + crates/rustynes-mappers/src/mapper.rs | 68 ++++++ crates/rustynes-netplay/src/message.rs | 65 +++++- crates/rustynes-ppu/src/bus.rs | 10 + crates/rustynes-ppu/src/ppu.rs | 179 ++++++++++++++- crates/rustynes-ppu/src/snapshot.rs | 28 ++- crates/rustynes-test-harness/src/lib.rs | 3 +- .../rustynes-test-harness/src/nes_runner.rs | 22 +- .../tests/cpu_overclock.rs | 201 +++++++++++++++++ .../tests/snapshot_schema_audit.rs | 22 ++ .../tests/sprite_limit.rs | 211 ++++++++++++++++++ ...and-netplay-carry-the-emulation-options.md | 18 ++ docs/frontend.md | 16 +- docs/mappers.md | 16 ++ docs/netplay-webrtc.md | 3 +- docs/ppu-2c02.md | 38 +++- docs/scheduler.md | 27 +++ to-dos/ROADMAP.md | 4 +- to-dos/plans/v3.1.0-plan.md | 3 + 35 files changed, 1435 insertions(+), 64 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/cpu_overclock.rs create mode 100644 crates/rustynes-test-harness/tests/sprite_limit.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8f38c66..98e0b28b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,11 @@ cycle-accurate core later replaced. - **Movies and netplay from v3.0.1 are refused, and so are its save states.** `EMULATION_EPOCH` rises from 2 to 3 (the two accuracy fixes below change bus - cycles and sprite evaluation), and the BUS save-state section moves to - version 3 for the DMC latch it now carries. + cycles and sprite evaluation). Save states: BUS section version 3 (the DMC + latch and the overclock's position) and `PPU_SNAPSHOT_VERSION` 13 (the + sprite-limit option's pending sprites). Movies: `.rnm` format 6 (the options + record gains the two options below). Netplay: protocol 7, magic `"RNE7"`; a + v3.0.x peer is refused as another emulator version, naming its epoch. ### Fixed @@ -56,9 +59,25 @@ cycle-accurate core later replaced. stale when an address column was added. The eight `Unofficial Immediates` rows are now spelled `immediate`, as the ROM prints them. - **The AccuracyCoin mirror ROM is rebuilt from `f5f41dc2`.** +- **Opening the pattern viewer, or using an HD pack, could change the game on + MMC2 / MMC4 boards** (Punch-Out!!, Fire Emblem). Their "side-effect-free" CHR + read went through the cartridge's normal read, which flips a CHR latch on + tiles `$FD`/`$FE`; the same held on three other boards. Those reads now + leave the cartridge exactly as it was. ### Added +- **CPU overclock** (`T-CPU-OVERCLOCK`, Settings > Enhancements): the CPU runs + 2 to 4 times faster against the same picture and sound, removing slowdown. + The APU, mapper IRQ counters and PPU timers stay at the stock rate, so pitch, + tempo and raster effects are unchanged. Unlike the extra-scanline overclock, + movies record it and replay with it, and netplay players must match. +- **"Disable 8-sprite-per-scanline limit" now works** (`T-SPRITE-LIMIT`; it was + shown and saved but inert). It draws the dropped sprites behind the eight the + console shows, and changes nothing the game can see: the overflow flag, the + sprite fetches and every CPU cycle stay exact. It is skipped on the five + boards whose pattern reads change the cartridge (MMC2, MMC4, the J.Y. ASIC, + Bandai 96, Nanjing 163). - **A test now enforces the emulation-epoch rule** (`T-EPOCH-FINGERPRINT`). It fingerprints seven test ROMs (frames, audio, RAM, CPU cycles) and fails when that output moves while `EMULATION_EPOCH` still equals the last diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index e8fa9304d..9d7676397 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -578,6 +578,37 @@ pub struct SystemBus { cpu_div_cached: u8, ppu_div_cached: u8, + /// v3.1.0 (`T-CPU-OVERCLOCK`): the CPU-multiplier overclock, `1..=4` + /// ([`crate::MAX_CPU_OVERCLOCK`]); `1` is stock. Configuration, like the + /// extra-scanline overclock: not part of the save-state, re-applied by + /// the host (and by a movie's or netplay session's options record). + cpu_overclock: u8, + /// The master clocks one CPU cycle takes under the overclock: + /// `cpu_div_cached / cpu_overclock`, rounded down, so `x3` on PAL (16) + /// is 5 (x3.2) and `x2` on Dendy (15) is 7 (x2.14). Equals + /// `cpu_div_cached` at `x1`. This is what [`Bus::cpu_divider`] returns. + cpu_div_effective: u8, + /// Master clocks the stock-rate domain is owed, `0..cpu_div_cached`. + /// + /// Under the overclock the APU, the DMC, the mappers' CPU-cycle hooks and + /// the PPU's open-bus / post-reset timers stay at the STOCK rate, so a + /// game gets more CPU time per frame without a pitch change, a faster + /// tempo or cycle-timed mapper IRQs firing early. Each CPU cycle adds + /// `cpu_div_effective`; a stock step runs whenever this reaches + /// `cpu_div_cached`. Unused (always 0) at `x1`, where every CPU cycle is a + /// stock step. In the BUS save-state section: run-ahead restores mid-run. + overclock_debt: u8, + /// The stock-rate domain's own cycle counter under the overclock, handed + /// to the APU in place of the CPU cycle counter (the APU derives its + /// put/get phase from it). Re-based to [`Self::cycle`] when the overclock + /// is switched on, so the phase continues; unused at `x1`. In the BUS + /// save-state section. + apu_cycle: u64, + /// Whether the cycle in progress is a stock step (set in `cpu_clock`, + /// read by `cpu_clock_apu_dmc` at the same cycle's end). Always `true` at + /// `x1`. Transient: a snapshot is taken between cycles. + stock_step: bool, + /// External CPU data bus latch: last value driven onto the bus /// by ANY device (CPU, DMC DMA, OAM DMA conflict reads). /// @@ -970,6 +1001,11 @@ impl SystemBus { ppu_clock: 0, cpu_div_cached, ppu_div_cached, + cpu_overclock: 1, + cpu_div_effective: cpu_div_cached, + overclock_debt: 0, + apu_cycle: 0, + stock_step: true, open_bus: 0, internal_data_bus: 0, last_read_addr: 0, @@ -1190,6 +1226,11 @@ impl SystemBus { // CPU/PPU phase into the "new" boot, diverging timing-sensitive games // from frame 0. Mirrors the `with_sample_rate` initial values. self.ppu_clock = 0; + // The stock-rate domain restarts with the clock; the multiplier itself + // is configuration and survives the power cycle. + self.overclock_debt = 0; + self.apu_cycle = 0; + self.stock_step = true; self.dma_byte = 0; self.dma_page = 0; self.last_read_addr = 0; @@ -1355,6 +1396,43 @@ impl SystemBus { self.ppu.extra_scanlines() } + /// v3.1.0 (`T-CPU-OVERCLOCK`) — set the CPU-multiplier overclock. The + /// caller ([`crate::Nes::set_cpu_overclock`]) has already clamped `k` to + /// `1..=MAX_CPU_OVERCLOCK`. Switching it ON re-bases the stock-rate + /// domain's cycle counter on the CPU's, so the APU's put/get phase + /// continues across the switch; switching it OFF hands the APU the CPU + /// counter again, as stock does. + pub const fn set_cpu_overclock(&mut self, k: u8) { + if k == self.cpu_overclock { + return; + } + if self.cpu_overclock == 1 { + self.apu_cycle = self.cycle; + } + self.cpu_overclock = k; + self.cpu_div_effective = self.cpu_div_cached / k; + self.overclock_debt = 0; + self.stock_step = true; + } + + /// v3.1.0 — the CPU-multiplier overclock (`1` = stock). + #[must_use] + pub const fn cpu_overclock(&self) -> u8 { + self.cpu_overclock + } + + /// v3.1.0 (`T-SPRITE-LIMIT`) — draw the sprites beyond the eighth on a + /// scanline (forwarded to the PPU). + pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { + self.ppu.set_sprite_limit_disabled(disabled); + } + + /// v3.1.0 — whether the sprites beyond the eighth are drawn. + #[must_use] + pub const fn sprite_limit_disabled(&self) -> bool { + self.ppu.sprite_limit_disabled() + } + /// v2.1.8 A1 — enable/disable the specialized visible-scanline fast dot /// path. `false` (default) is byte-identical to a build without it. See /// [`rustynes_ppu::Ppu::set_fast_dotloop`]. @@ -1656,7 +1734,21 @@ impl SystemBus { pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { - 0x0000..=0x1FFF => self.mapper.ppu_read(addr), + // v3.1.0: on the five boards whose CHR read changes them (MMC2 and + // MMC4 latches, the J.Y. ASIC's read-clocked IRQ, Bandai 96 and + // Nanjing 163; `Mapper::chr_reads_are_pure`) the read is bracketed + // by the board's own save / load, so a debugger panel or an HD-pack + // tile hash no longer changes the game. Until v3.1.0 opening the + // pattern viewer on Punch-Out!! flipped its CHR latch. Free on every + // other board. + 0x0000..=0x1FFF if self.mapper.chr_reads_are_pure() => self.mapper.ppu_read(addr), + 0x0000..=0x1FFF => { + let saved = self.mapper.save_state(); + let v = self.mapper.ppu_read(addr); + let restored = self.mapper.load_state(&saved); + debug_assert!(restored.is_ok(), "a board must reload its own state"); + v + } 0x2000..=0x3EFF => { let addr = if addr >= 0x3000 && !self.mapper.nametable_unfolded() { addr - 0x1000 @@ -2787,6 +2879,8 @@ impl SystemBus { // identical across feature builds. dmc_halt: self.dmc_halt, dmc_load_write_delayed: self.dmc_load_write_delayed, + overclock_debt: self.overclock_debt, + apu_cycle: self.apu_cycle, uni_oam_active: self.uni_oam_active, uni_oam_halt: self.uni_oam_halt, uni_oam_aligned: self.uni_oam_aligned, @@ -2819,6 +2913,18 @@ impl SystemBus { // reproduces them EXACTLY instead of by assumption. self.dmc_halt = s.dmc_halt; self.dmc_load_write_delayed = s.dmc_load_write_delayed; + // The overclock's stock-rate position. The multiplier itself is + // configuration (re-applied by the host); a debt the current divider + // could not have produced is clamped below one stock cycle so a stock + // step still comes due. + let max_debt = self.cpu_div_cached.saturating_sub(1); + self.overclock_debt = if s.overclock_debt > max_debt { + max_debt + } else { + s.overclock_debt + }; + self.apu_cycle = s.apu_cycle; + self.stock_step = true; self.uni_oam_active = s.uni_oam_active; self.uni_oam_halt = s.uni_oam_halt; self.uni_oam_aligned = s.uni_oam_aligned; @@ -3758,6 +3864,10 @@ impl PpuBus for PpuBusAdapter<'_> { fn ppu_read(&mut self, addr: u16) -> u8 { self.mapper.ppu_read(addr & 0x1FFF) } + + fn chr_reads_are_pure(&self) -> bool { + self.mapper.chr_reads_are_pure() + } fn ppu_read_sprite(&mut self, addr: u16) -> u8 { self.mapper.ppu_read_sprite(addr & 0x1FFF) } @@ -3852,7 +3962,7 @@ impl SystemBus { /// the default `mix_audio` (0.0 after the f32 conversion — identical), /// and boards without the frame hook have the default no-op. Skipping /// both saves two virtual calls + an f32 divide per CPU cycle. - fn apu_advance_one(&mut self) { + fn apu_advance_one(&mut self, apu_cycle: u64) { // `Mapper::mix_audio` returns i32 (widened from i16 in v2.2.3 so the // Sunsoft 5B's ~3.6x full-volume level is representable); scale it to // about the APU mixer's own [-0.5, 0.5] range. `as f32` rather than @@ -3869,7 +3979,9 @@ impl SystemBus { // bus cycle counter (incremented earlier in this same `cpu_clock`) // instead of letting it keep an independent `+= 1` mirror (the // one-clock collapse, promoted to the only path in v2.0.0 beta.4). - self.apu.set_canonical_cycle(self.cycle); + // v3.1.0: `apu_cycle` is that same counter at `x1`, and the stock-rate + // domain's own counter under the CPU overclock. + self.apu.set_canonical_cycle(apu_cycle); self.apu.tick_with_external(mapper_sample); if self.mapper_caps.frame_event_hook { let ev = self.apu.last_frame_events(); @@ -4238,7 +4350,8 @@ impl Bus for SystemBus { /// Drives the CPU loop's `master_clock` advance + read/write split so the /// CPU<->PPU phase is 3:1 NTSC, 3.2:1 PAL, 3:1 Dendy. fn cpu_divider(&self) -> u64 { - u64::from(self.cpu_div_cached) + // The overclocked CPU cycle length; `cpu_div_cached` at `x1`. + u64::from(self.cpu_div_effective) } /// R1 double catch-up: tick whole PPU dots while @@ -4336,6 +4449,24 @@ impl Bus for SystemBus { } } self.cycle = self.cycle.wrapping_add(1); + // v3.1.0 (`T-CPU-OVERCLOCK`): under the overclock only some CPU cycles + // are stock steps, and everything below this point that measures + // console time (the PPU's decay / post-reset timers, the mappers' + // M2-cycle IRQ counters, the APU) runs on those only. At `x1` the + // branch is never taken: every cycle is a stock step and the APU gets + // the CPU counter, exactly as before. + let apu_cycle = if self.cpu_overclock == 1 { + self.cycle + } else { + self.overclock_debt += self.cpu_div_effective; + self.stock_step = self.overclock_debt >= self.cpu_div_cached; + if !self.stock_step { + return; + } + self.overclock_debt -= self.cpu_div_cached; + self.apu_cycle = self.apu_cycle.wrapping_add(1); + self.apu_cycle + }; self.ppu.on_cpu_cycle(); // v2.8.0 Phase 4 — skip the virtual dispatch on boards whose // `notify_cpu_cycle` is the default no-op (capability-flag cache). @@ -4345,7 +4476,7 @@ impl Bus for SystemBus { // F-2: `apu_advance_one` (start) ticks the whole APU EXCEPT the DMC // byte-timer (gated out by `dmc_driven_externally`); the DMC is ticked // at end-of-cycle by `cpu_clock_apu_dmc`. - self.apu_advance_one(); + self.apu_advance_one(apu_cycle); // (W2 $2007 Stress) The deferred $2007 render-buffer reload is now // PPU-dot-scheduled and consumed inside `Ppu::tick` — the prior // per-CPU-cycle `apply_pending_render_buffer` hook here was quantized @@ -4359,6 +4490,11 @@ impl Bus for SystemBus { // matching Mesen's `ProcessCpuClock` at `StartCpuCycle`. So the END-of-cycle // DMC tick is a no-op here. fn cpu_clock_apu_dmc(&mut self) { + // v3.1.0: the DMC end-of-cycle half belongs to the stock step its + // start half ran in (always `true` at `x1`). + if !self.stock_step { + return; + } // v2.0 Program M (M-1): clock the DMC byte-timer + arm the reload HERE at // end-of-cycle (after the CPU's bus access), the references' within-cycle // order. When the flag is OFF the byte-timer stays at cycle-start (above, @@ -4759,10 +4895,20 @@ mod four_score_tests { // first unassigned tag is 10. let bus = test_bus(); let mut bad = crate::bus_snapshot::encode_bus(&bus); - // The two device tags sit 25 bytes before the end with both ports - // empty: mirroring override (1) + controller-run tail (22) + - // internal bus (1) follow them, and port 1's tag is the second. - let port0_tag = bad.len() - 1 - 22 - 1 - 2; + // The two device tags sit before a fixed tail with both ports empty: + // mirroring override (1) + controller-run tail (22) + internal bus + // (1) + the version-3 fields (DMC write-refusal latch 1, overclock + // debt 1, `apu_cycle` 8) follow them, and port 1's tag is the second. + // + // v3.1.0: the version-3 bytes were missing from this sum for one + // commit. With only the 1-byte latch appended the window still read + // `[0, 0]` (the mirroring byte and port 1's tag), and the 10 written + // into the mirroring byte was refused for ITS own reason, so the + // test passed while testing something else. The assertion below + // that the window holds the two empty tags is what caught it once + // the overclock added 9 more bytes. + let tail = 1 + 22 + 1 + (1 + 1 + 8); + let port0_tag = bad.len() - tail - 2; assert_eq!(&bad[port0_tag..port0_tag + 2], &[0, 0]); bad[port0_tag] = 10; assert!(matches!( diff --git a/crates/rustynes-core/src/bus_snapshot.rs b/crates/rustynes-core/src/bus_snapshot.rs index 40cf634f5..352e8ed2b 100644 --- a/crates/rustynes-core/src/bus_snapshot.rs +++ b/crates/rustynes-core/src/bus_snapshot.rs @@ -21,13 +21,20 @@ //! //! # Version 3 (v3.1.0) //! -//! Appends one byte after the internal data bus: the DMC load-DMA -//! write-refusal latch (`dmc_load_write_delayed`). It outlives an -//! instruction, because the refusing write is the last cycle of a store and -//! the latch is consumed by the next opcode fetch, so a snapshot at that -//! boundary without it would restore a three-cycle load where the machine -//! was owed a four-cycle one. Version 2 is refused rather than read with a -//! default, as the version-2 rules above require. +//! Appends, after the internal data bus: +//! +//! - the DMC load-DMA write-refusal latch (`dmc_load_write_delayed`, one +//! byte). It outlives an instruction, because the refusing write is the +//! last cycle of a store and the latch is consumed by the next opcode +//! fetch, so a snapshot at that boundary without it would restore a +//! three-cycle load where the machine was owed a four-cycle one; +//! - the CPU overclock's stock-rate position (`overclock_debt`, one byte, +//! and `apu_cycle`, `u64`): under the overclock the APU and the mappers' +//! cycle hooks advance on only some CPU cycles, and run-ahead restores in +//! the middle of that pattern. +//! +//! Version 2 is refused rather than read with a default, as the version-2 +//! rules above require. use crate::bus::SystemBus; use crate::controller::Controller; @@ -146,6 +153,9 @@ pub fn encode_bus(bus: &SystemBus) -> Vec { w.u8(s.internal_data_bus); // v3.1.0 (version 3): the DMC load-DMA write-refusal latch. w.u8(u8::from(s.dmc_load_write_delayed)); + // v3.1.0 (version 3): the CPU overclock's stock-rate position. + w.u8(s.overclock_debt); + w.u64(s.apu_cycle); w.into_vec() } @@ -460,6 +470,17 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> bus.set_four_score_pending(fs); let internal_data_bus = r.u8()?; let dmc_load_write_delayed = r.bool()?; + let overclock_debt = r.u8()?; + // A debt is below one stock CPU cycle (16 master clocks on PAL, the + // longest); anything larger is a corrupt file, refused here rather than + // clamped later. + if overclock_debt >= 16 { + return Err(SnapshotError::SectionInvalid { + tag: "BUS ".into(), + reason: format!("CPU-overclock debt {overclock_debt} out of range"), + }); + } + let apu_cycle = r.u64()?; if r.remaining() != 0 { return Err(SnapshotError::SectionInvalid { tag: "BUS ".into(), @@ -483,6 +504,8 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> four_score_sig, dmc_halt, dmc_load_write_delayed, + overclock_debt, + apu_cycle, uni_oam_active, uni_oam_halt, uni_oam_aligned, @@ -550,6 +573,12 @@ pub struct BusMiscState { /// v3.1.0 (BUS version 3): a pending load DMC DMA was refused by a CPU /// write and enters on the next read whichever half it is. pub dmc_load_write_delayed: bool, + /// v3.1.0 (BUS version 3): master clocks the CPU overclock's stock-rate + /// domain is owed (always 0 at `x1`). + pub overclock_debt: u8, + /// v3.1.0 (BUS version 3): the stock-rate domain's cycle counter under + /// the CPU overclock (unused at `x1`). + pub apu_cycle: u64, /// W3-Stage-4: unified DMA engine (`mc-r1-dma-unified`) — OAM DMA active /// (`TriCNES` `DoOAMDMA`). pub uni_oam_active: bool, diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index 8603907cf..c18a6428b 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -116,6 +116,10 @@ pub const EMULATION_EPOCH: u32 = 3; /// option is then not a break. #[derive(Clone, Debug, Eq, PartialEq, Hash)] #[non_exhaustive] +// Four independent switches (OAM decay, Four Score, the Zapper light model, +// the sprite limit), each mirroring one `Nes` setter one-to-one; no two are +// states of one thing, so an enum or bitflags would only obscure the mapping. +#[allow(clippy::struct_excessive_bools)] pub struct HardwareOptions { /// Which console's reset wiring is modelled ([`Nes::set_console_model`]). pub console_model: ConsoleModel, @@ -131,6 +135,12 @@ pub struct HardwareOptions { pub power_up_palette: PaletteInit, /// The extra-vblank-scanline overclock ([`Nes::set_extra_scanlines`]). pub extra_scanlines: u16, + /// The CPU-multiplier overclock, `1..=MAX_CPU_OVERCLOCK` + /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. + pub cpu_overclock: u8, + /// Draw the sprites beyond the eighth on a scanline + /// ([`Nes::set_sprite_limit_disabled`]); render-only. v3.1.0. + pub sprite_limit_disabled: bool, /// Whether the Four Score adapter is plugged in ([`Nes::set_four_score`]). /// It changes `$4016` / `$4017` reads 9-24 even with players 3/4 idle. pub four_score: bool, @@ -161,6 +171,8 @@ impl Default for HardwareOptions { power_on_ram: PowerOnRam::default(), power_up_palette: PaletteInit::default(), extra_scanlines: 0, + cpu_overclock: 1, + sprite_limit_disabled: false, four_score: false, // The core's own default since v2.2.x (`zapper_temporal_light` // is on in a freshly-built `Nes`); the stock machine, not `false`. @@ -185,6 +197,8 @@ impl HardwareOptions { power_on_ram: nes.power_on_ram(), power_up_palette: nes.power_up_palette(), extra_scanlines: nes.extra_scanlines(), + cpu_overclock: nes.cpu_overclock(), + sprite_limit_disabled: nes.sprite_limit_disabled(), four_score: nes.four_score(), zapper_temporal_light: nes.zapper_temporal_light(), vs_dip: nes.vs_dip(), @@ -240,6 +254,12 @@ impl HardwareOptions { if nes.extra_scanlines() != self.extra_scanlines { nes.set_extra_scanlines(self.extra_scanlines); } + if nes.cpu_overclock() != self.cpu_overclock { + nes.set_cpu_overclock(self.cpu_overclock); + } + if nes.sprite_limit_disabled() != self.sprite_limit_disabled { + nes.set_sprite_limit_disabled(self.sprite_limit_disabled); + } if nes.four_score() != self.four_score { nes.set_four_score(self.four_score); } @@ -307,7 +327,9 @@ impl HardwareOptions { /// /// Layout (all little-endian): console model, PPU revision, 2A03 /// revision, OAM decay, power-on RAM kind + `u64` payload, power-up - /// palette, extra scanlines (`u16`), Four Score, Zapper light model, Vs. + /// palette, extra scanlines (`u16`), CPU overclock and the sprite-limit + /// flag (both since `.rnm` format 6 and netplay protocol 7, v3.1.0), Four + /// Score, Zapper light model, Vs. /// DIP, Vs. PPU type (`0xFF` = the header's), mirroring override (`0` = /// none, else variant + 1), then a code count and each Game Genie code as /// a length byte plus ASCII. Every enum is an explicit byte, never a @@ -339,6 +361,8 @@ impl HardwareOptions { PaletteInit::Blargg => 1, }); w.u16(self.extra_scanlines); + w.u8(self.cpu_overclock); + w.u8(u8::from(self.sprite_limit_disabled)); w.u8(u8::from(self.four_score)); w.u8(u8::from(self.zapper_temporal_light)); w.u8(self.vs_dip); @@ -402,6 +426,13 @@ impl HardwareOptions { if extra_scanlines > crate::nes::MAX_EXTRA_SCANLINES { return Err("extra-scanline overclock is above the core's maximum"); } + // The core clamps the multiplier to 1..=MAX_CPU_OVERCLOCK, so any other + // byte could not replay as written. + let cpu_overclock = byte(r)?; + if cpu_overclock == 0 || cpu_overclock > crate::nes::MAX_CPU_OVERCLOCK { + return Err("CPU overclock is outside 1..=MAX_CPU_OVERCLOCK"); + } + let sprite_limit_disabled = flag(r, "sprite-limit flag is not 0 or 1")?; let four_score = flag(r, "Four Score flag is not 0 or 1")?; let zapper_temporal_light = flag(r, "Zapper light flag is not 0 or 1")?; let vs_dip = byte(r)?; @@ -436,6 +467,8 @@ impl HardwareOptions { power_on_ram, power_up_palette, extra_scanlines, + cpu_overclock, + sprite_limit_disabled, four_score, zapper_temporal_light, vs_dip, @@ -480,6 +513,11 @@ impl HardwareOptions { self.extra_scanlines != other.extra_scanlines, "overclock scanlines", ); + check(self.cpu_overclock != other.cpu_overclock, "CPU overclock"); + check( + self.sprite_limit_disabled != other.sprite_limit_disabled, + "sprite limit", + ); check(self.four_score != other.four_score, "Four Score"); check( self.zapper_temporal_light != other.zapper_temporal_light, @@ -774,6 +812,8 @@ mod tests { power_on_ram: PowerOnRam::Seeded(0x0123_4567_89AB_CDEF), power_up_palette: PaletteInit::Blargg, extra_scanlines: 40, + cpu_overclock: 3, + sprite_limit_disabled: true, four_score: true, zapper_temporal_light: false, vs_dip: 0xA5, diff --git a/crates/rustynes-core/src/lib.rs b/crates/rustynes-core/src/lib.rs index 11aa9bbc2..73073b81c 100644 --- a/crates/rustynes-core/src/lib.rs +++ b/crates/rustynes-core/src/lib.rs @@ -98,7 +98,7 @@ pub use movie::{ #[cfg(feature = "debug-hooks")] pub use nes::TraceRec; pub use nes::{ - ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, + ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, MAX_CPU_OVERCLOCK, MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam, }; // v2.1.7 P5 — re-export the PPU-side hardware-revision knobs at the core surface diff --git a/crates/rustynes-core/src/movie.rs b/crates/rustynes-core/src/movie.rs index d368fea7a..e3b6d1a47 100644 --- a/crates/rustynes-core/src/movie.rs +++ b/crates/rustynes-core/src/movie.rs @@ -93,12 +93,20 @@ pub const MOVIE_MAGIC: &[u8; 8] = b"RNESMOV1"; /// `$1000` differently (T-MMC3-BG-A12), and a format-4 movie does not say /// which behaviour it assumes, so v4 is refused. A v5 movie from another /// epoch is refused with [`MovieError::EpochMismatch`]. -pub const MOVIE_FORMAT_VERSION: u16 = 5; - -/// The oldest container version this build replays: v5, the first to record -/// the emulation epoch (v4 identified the board, v3 the options). Older -/// movies fail with [`MovieError::FormatTooOld`]. -pub const MIN_MOVIE_FORMAT_VERSION: u16 = 5; +/// - **v6 (v3.1.0)**: the [`crate::HardwareOptions`] record gains the +/// CPU-multiplier overclock and the sprite-limit option (`T-CPU-OVERCLOCK`, +/// `T-SPRITE-LIMIT`). A v5 options record is one field shorter and would +/// decode as garbage, so v5 is refused. No replayable movie is lost: every +/// v5 movie was recorded under epoch 1 or 2, which v3.1.0 (epoch 3) refuses +/// anyway. +pub const MOVIE_FORMAT_VERSION: u16 = 6; + +/// The oldest container version this build replays: v6. +/// +/// v6 is the first whose options record carries the CPU overclock and the +/// sprite-limit option (v5 first recorded the emulation epoch, v4 the board, +/// v3 the options). Older movies fail with [`MovieError::FormatTooOld`]. +pub const MIN_MOVIE_FORMAT_VERSION: u16 = 6; /// Peek a `.rnm` blob's header to learn its recording epoch. /// @@ -2391,7 +2399,32 @@ mod tests { bytes[8..10].copy_from_slice(&4u16.to_le_bytes()); assert!(matches!( Movie::deserialize(&bytes), - Err(MovieError::FormatTooOld { got: 4, min: 5 }) + Err(MovieError::FormatTooOld { + got: 4, + min: MIN_MOVIE_FORMAT_VERSION + }) + )); + } + + /// v3.1.0: a format-5 movie (v3.0.x) carries the options record without + /// the CPU overclock and the sprite-limit option, so it is refused as too + /// old rather than decoded one field short. + #[test] + fn a_format_5_movie_is_refused_as_too_old() { + assert_eq!(MIN_MOVIE_FORMAT_VERSION, 6); + let mut bytes = Movie::new( + Region::Ntsc, + [0; 32], + crate::HardwareOptions::default(), + None, + StartPoint::PowerOn, + vec![], + ) + .serialize(); + bytes[8..10].copy_from_slice(&5u16.to_le_bytes()); + assert!(matches!( + Movie::deserialize(&bytes), + Err(MovieError::FormatTooOld { got: 5, min: 6 }) )); } diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 062b9b7f6..65df7dc20 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -159,6 +159,16 @@ pub struct TraceRec { /// host and every file format meets the same bound. pub const MAX_EXTRA_SCANLINES: u16 = 80; +/// The largest CPU-multiplier overclock the core accepts (v3.1.0, +/// `T-CPU-OVERCLOCK`). +/// +/// [`Nes::set_cpu_overclock`] clamps to `1..=MAX_CPU_OVERCLOCK`, and a movie's +/// options record outside that range is refused. The bound is the master +/// clock: the CPU divider must stay at least 3 for the read / write split of +/// a cycle (NTSC 12 / 4 = 3), and `x4` is already four times the CPU time a +/// frame has. +pub const MAX_CPU_OVERCLOCK: u8 = 4; + /// Top-level NES emulator handle. /// /// Owns the CPU, PPU, mapper, RAM, and controller stub. Construct via @@ -719,7 +729,11 @@ impl Nes { // Hard cap: at NTSC the frame budget is 29,780.5 CPU cycles. Run // up to 5x that before bailing — gives breathing room for late // VBL detection or DMA-stall heavy frames before declaring "stuck". + // + // v3.1.0: scaled by the CPU overclock, which puts up to + // MAX_CPU_OVERCLOCK times as many CPU cycles into one frame. const MAX_CYCLES_PER_FRAME: u64 = 150_000; + let max_cycles = MAX_CYCLES_PER_FRAME * u64::from(self.bus.cpu_overclock()); let start = self.bus.cycle(); // v2.3.7 "Overtone" — anchor this frame's mix trace. The trace is // per-frame (the index IS the cycle offset from here); the REGISTER @@ -765,7 +779,7 @@ impl Nes { if self.cpu.is_jammed() { break; } - if self.bus.cycle().wrapping_sub(start) > MAX_CYCLES_PER_FRAME { + if self.bus.cycle().wrapping_sub(start) > max_cycles { break; } #[cfg(feature = "debug-hooks")] @@ -3010,7 +3024,8 @@ impl Nes { /// default) is **byte-identical** to stock NES timing — `AccuracyCoin`, the /// commercial oracle, and nestest (which never set it) are unaffected. /// **Off by default**; a frontend config knob, not part of the save-state. - /// Distinct from the CPU-multiplier overclock (a v2.0 timebase item). + /// Distinct from the CPU-multiplier overclock ([`Self::set_cpu_overclock`], + /// v3.1.0), which shortens the CPU cycle instead of lengthening the frame. /// /// Clamped to [`MAX_EXTRA_SCANLINES`] (v2.9.9, NC-11): the cap used to /// live only in the desktop frontend, so a movie's options record could @@ -3025,6 +3040,67 @@ impl Nes { self.bus.set_extra_scanlines(lines); } + /// v3.1.0 (`T-CPU-OVERCLOCK`, FE-01) — set the CPU-multiplier overclock: + /// the CPU runs `k` times faster against an unchanged PPU, so a game gets + /// `k` times the CPU time per frame. + /// + /// It divides the region's master-clock CPU divider (NTSC 12 -> 6 / 4 / 3; + /// PAL 16 and Dendy 15 round down, so `x3` on PAL is x3.2). The APU, the + /// DMC, every mapper's CPU-cycle hook (the VRC / FME-7 / N163 IRQ + /// counters) and the PPU's open-bus and post-reset timers stay at the + /// STOCK rate, so the pitch, the music tempo and the cycle-timed raster + /// IRQs are unchanged: what speeds up is the game's own code. DMA follows + /// the APU's get/put phase, so a DMA takes about `k` times as many CPU + /// cycles and the same real time. + /// + /// `1` (the default) is stock and byte-identical to a build without the + /// option. `0` is treated as `1`, and values above [`MAX_CPU_OVERCLOCK`] + /// clamp to it. Configuration, not save-state: the host re-applies it, + /// and movies and netplay carry it in [`crate::HardwareOptions`]. Not + /// hardware behaviour: no real console runs its CPU faster than its APU. + pub const fn set_cpu_overclock(&mut self, k: u8) { + let k = if k == 0 { + 1 + } else if k > MAX_CPU_OVERCLOCK { + MAX_CPU_OVERCLOCK + } else { + k + }; + self.bus.set_cpu_overclock(k); + } + + /// v3.1.0 — the CPU-multiplier overclock (`1` = stock). + #[must_use] + pub const fn cpu_overclock(&self) -> u8 { + self.bus.cpu_overclock() + } + + /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02) — draw the sprites beyond the eighth + /// on a scanline, removing sprite flicker. + /// + /// **Render-only.** Sprite evaluation, secondary OAM, the overflow flag, + /// sprite-0 hit and every real sprite fetch (with its A12 edges, which an + /// MMC3 counts) are exactly stock, so the game sees no difference; only + /// the picture does. The extra sprites draw behind all eight hardware ones. + /// On the boards whose CHR reads have an effect (MMC2, MMC4, the J.Y. + /// ASIC, Bandai 96, Nanjing 163; `Mapper::chr_reads_are_pure`) the option + /// draws eight, as stock, because the extra pattern reads would change + /// emulation there. + /// + /// Off by default. Configuration, not save-state; carried across a power + /// cycle, and in movies and netplay by [`crate::HardwareOptions`] (the + /// picture differs, so a movie's frame hashes and a netplay peer's desync + /// checks depend on it). + pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { + self.bus.set_sprite_limit_disabled(disabled); + } + + /// v3.1.0 — whether the sprites beyond the eighth are drawn. + #[must_use] + pub const fn sprite_limit_disabled(&self) -> bool { + self.bus.sprite_limit_disabled() + } + /// v1.7.0 F3 — the configured extra-scanline overclock count (`0` = stock). #[must_use] pub const fn extra_scanlines(&self) -> u16 { @@ -4278,6 +4354,35 @@ mod tests { (Some(RamSite::PrgWindow), sweep_diff(&want, &got)) } + /// v3.1.0 — `debug_peek_ppu` is the debugger's and the HD-pack + /// compositor's "side-effect-free" CHR read. On the five boards whose CHR + /// reads change them (`Mapper::chr_reads_are_pure`), it used to flip the + /// board: reading all of CHR on MMC2 or MMC4 (the pattern viewer does) + /// passes tiles `$FD`/`$FE` and switches a CHR latch, so opening a debugger + /// panel, or hashing a tile for an HD pack, changed the game. Every board's + /// whole-machine state must be unchanged by a full sweep of peeks. + #[test] + fn debug_peek_ppu_changes_no_board() { + // The five impure families' ids, and MMC3 (4) as a pure control. + for id in [9u16, 10, 35, 90, 96, 163, 209, 211, 4] { + let rom = synth_board_rom(id, SweepHeader::Ines1, 128, 128); + let Ok(mut nes) = Nes::from_rom(&rom) else { + panic!("mapper {id} builds"); + }; + nes.run_frame(); + let before = nes.snapshot(); + for a in 0u16..0x2000 { + let _ = nes.bus.debug_peek_ppu(a); + } + let after = nes.snapshot(); + assert!( + before == after, + "mapper {id}: peeking CHR changed the machine ({} differing snapshot bytes)", + before.iter().zip(&after).filter(|(a, b)| a != b).count() + ); + } + } + /// CHR-RAM round trip through the whole-machine snapshot, through PPU /// `$0000-$1FFF` (there is no raw CHR accessor on `Mapper`). Returns /// `None` for the site when fewer than 1 KiB of the pattern reads back, diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 4ca001d1a..ec15a30c5 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -2112,6 +2112,8 @@ impl App { // take the lock until they are in place. The overclock applies at // the top of each produced frame (v2.9.7). emu.overclock_scanlines = self.config.enhancements.overclock_scanlines; + emu.cpu_overclock = self.config.enhancements.cpu_overclock; + emu.disable_sprite_limit = self.config.enhancements.disable_sprite_limit; #[cfg(not(target_arch = "wasm32"))] if let Some(raw) = raw_cheats { emu.raw_cheats = raw; @@ -6835,7 +6837,14 @@ impl App { /// (v2.9.8), and a Power Cycle leaves it alone (it is not console state). fn apply_overclock(&self) { let lines = self.config.enhancements.overclock_scanlines; - self.emu.lock().overclock_scanlines = lines; + let cpu = self.config.enhancements.cpu_overclock; + let sprites = self.config.enhancements.disable_sprite_limit; + let mut emu = self.emu.lock(); + emu.overclock_scanlines = lines; + // v3.1.0 — the CPU-multiplier overclock and the sprite-limit option + // ride the same push. + emu.cpu_overclock = cpu; + emu.disable_sprite_limit = sprites; } /// v2.1.7 P5 — push the opt-in PPU hardware-revision + power-on knobs from @@ -9741,6 +9750,8 @@ impl App { // v2.9.8 — the overclock lives on `EmuCore` (applied at the top of // each produced frame); set before the console is installed. emu.overclock_scanlines = self.config.enhancements.overclock_scanlines; + emu.cpu_overclock = self.config.enhancements.cpu_overclock; + emu.disable_sprite_limit = self.config.enhancements.disable_sprite_limit; // Capture the cartridge's nominal frame duration — consults the // cartridge region (NTSC: ~16.64 ms, PAL/Dendy: ~20 ms). emu.frame_duration = nes.frame_duration(); diff --git a/crates/rustynes-frontend/src/config.rs b/crates/rustynes-frontend/src/config.rs index a71ed259d..5ae3d3abc 100644 --- a/crates/rustynes-frontend/src/config.rs +++ b/crates/rustynes-frontend/src/config.rs @@ -1790,9 +1790,12 @@ pub struct Config { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] pub struct EnhancementsConfig { /// Disable the hardware 8-sprite-per-scanline limit (removes sprite - /// flicker). Off by default = accurate hardware behaviour. **Staged**: the - /// current cycle-accurate core has no no-sprite-limit hook, so this is - /// persisted + surfaced but inert until the v2.0 core pass (ADR 0002). + /// flicker). Off by default = accurate hardware behaviour. v3.1.0 + /// (`T-SPRITE-LIMIT`): applied to the core (`Nes::set_sprite_limit_disabled`) + /// from the next frame. Render-only: evaluation, the overflow flag and the + /// sprite fetches stay exact, so the game sees no difference. Like + /// `cpu_overclock` a movie records it and netplay peers must match. Until + /// v3.1.0 it was persisted and shown but nothing read it. #[serde(default)] pub disable_sprite_limit: bool, /// Optional overclock: extra emulated PPU scanlines inserted in the @@ -1804,6 +1807,15 @@ pub struct EnhancementsConfig { /// persisted and shown but nothing read it. #[serde(default)] pub overclock_scanlines: u16, + /// v3.1.0 (`T-CPU-OVERCLOCK`): the CPU-multiplier overclock, `2..=4` for + /// x2 to x4; `0` (the default) and `1` are stock. The CPU runs that many + /// times faster against the same picture and sound + /// (`Nes::set_cpu_overclock`). Unlike `overclock_scanlines` it is not + /// held at stock under a movie or netplay: a movie records it and replays + /// with it, and netplay peers must match (both through the core's + /// `HardwareOptions`). + #[serde(default)] + pub cpu_overclock: u8, } /// v2.1.4 F2.3 — the `[emulation]` section: optional **accuracy** toggles. diff --git a/crates/rustynes-frontend/src/debugger/settings_panel.rs b/crates/rustynes-frontend/src/debugger/settings_panel.rs index 77bc60652..720309d46 100644 --- a/crates/rustynes-frontend/src/debugger/settings_panel.rs +++ b/crates/rustynes-frontend/src/debugger/settings_panel.rs @@ -1931,13 +1931,17 @@ fn enhancements_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi .show(ui, |ui| { ui.weak(crate::t!(SetEnhancementsNote)); - let mut changed = false; - changed |= ui + // v3.1.0: live. The checkbox pushes through the same apply flag as + // the overclocks (`App::apply_overclock`). + let mut changed = ui .checkbox( &mut config.enhancements.disable_sprite_limit, crate::t!(SetDisableSpriteLimit), ) .changed(); + if changed { + state.apply.overclock = true; + } ui.indent("enh-sprite-note", |ui| { ui.weak(crate::t!(SetEnhSpriteInert)); }); @@ -1960,6 +1964,37 @@ fn enhancements_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi ui.weak(crate::t!(SetEnhOverclockNote)); }); + // v3.1.0 (`T-CPU-OVERCLOCK`): the CPU-multiplier overclock. 0 and 1 + // both mean stock; the combo writes 0 for stock so an untouched + // config stays at its default. + ui.horizontal(|ui| { + ui.label(crate::t!(SetCpuOverclock)); + let current = config.enhancements.cpu_overclock.max(1); + let label = |k: u8| { + if k == 1 { + crate::t!(SetCpuOverclockOff).to_string() + } else { + format!("x{k}") + } + }; + let mut chosen = current; + egui::ComboBox::from_id_salt("enh-cpu-overclock") + .selected_text(label(current)) + .show_ui(ui, |ui| { + for k in 1..=rustynes_core::MAX_CPU_OVERCLOCK { + ui.selectable_value(&mut chosen, k, label(k)); + } + }); + if chosen != current { + config.enhancements.cpu_overclock = if chosen == 1 { 0 } else { chosen }; + state.apply.overclock = true; + changed = true; + } + }); + ui.indent("enh-cpu-overclock-note", |ui| { + ui.weak(crate::t!(SetEnhCpuOverclockNote)); + }); + // The max-rewind window cross-links the Rewind group above (the // enhancement-adjacent third knob), surfaced here for grouping. ui.separator(); @@ -2019,9 +2054,11 @@ mod tests { let mut state = SettingsPanelState::default(); let mut config = Config::default(); config.enhancements.overclock_scanlines = 40; + config.enhancements.cpu_overclock = 3; config.emulation.famicom_console = true; reset_advanced(&mut state, &mut config); assert_eq!(config.enhancements.overclock_scanlines, 0); + assert_eq!(config.enhancements.cpu_overclock, 0, "back to stock"); assert!(!config.emulation.famicom_console, "back to the NES model"); let apply = state.take_apply(); assert!(apply.overclock, "the reset overclock reaches the core"); diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index c647993c6..32d63f74b 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -505,6 +505,11 @@ impl core::fmt::Display for RestoreStateError { } /// The emulation core: the per-frame produce state extracted from `App`. +// The produce loop's state bag: its flags (HD capture, the write lock, the +// sprite-limit option, the run-ahead throttle) are unrelated switches read in +// different places, not states of one thing. (v3.1.0's `disable_sprite_limit` +// made the `hd-pack` build count four.) +#[allow(clippy::struct_excessive_bools)] pub struct EmuCore { /// The running single-console emulator (None until a single-console ROM is /// loaded, or while a Vs. `DualSystem` cabinet is loaded — see [`Self::dual`]). @@ -601,6 +606,15 @@ pub struct EmuCore { /// `0` (the default) is stock timing, byte-identical to a core that never /// heard of the setting. pub overclock_scanlines: u16, + /// v3.1.0 — the configured CPU-multiplier overclock (`[enhancements] + /// cpu_overclock`, pushed by `App` beside `overclock_scanlines`). Applied + /// at the top of every produced frame while no movie is recording or + /// playing; during a movie the movie's own options hold the console (its + /// recorded multiplier). `0` and `1` are stock. + pub cpu_overclock: u8, + /// v3.1.0 — the configured sprite-limit option (`[enhancements] + /// disable_sprite_limit`), applied like `cpu_overclock`. + pub disable_sprite_limit: bool, /// Vs. System coin-hold countdown (frames until `clear_coin`). pub vs_coin_frames: u8, /// Per-region frame duration (NTSC ~16.639 ms, PAL/Dendy ~19.997 ms). @@ -1089,6 +1103,8 @@ impl EmuCore { debug_pokes: Vec::new(), writes_locked: false, overclock_scanlines: 0, + cpu_overclock: 0, + disable_sprite_limit: false, vs_coin_frames: 0, frame_duration: rustynes_core::FRAME_DURATION_NTSC, speed: 1.0, @@ -1635,6 +1651,18 @@ impl EmuCore { if nes.extra_scanlines() != extra_lines { nes.set_extra_scanlines(extra_lines); } + // v3.1.0 — the CPU overclock applies only outside a movie session: a + // recording captures what the console runs with when it starts, and a + // playing movie holds the console to its own options every frame. + if self.movie.mode() == crate::movie_ui::MovieMode::Idle { + let want = self.cpu_overclock.max(1); + if nes.cpu_overclock() != want { + nes.set_cpu_overclock(want); + } + if nes.sprite_limit_disabled() != self.disable_sprite_limit { + nes.set_sprite_limit_disabled(self.disable_sprite_limit); + } + } // v2.7.0 — RetroAchievements hardcore mode disables rewind (already // folded into `inputs.rewind_held` by `App`). let rewinding = inputs.rewind_held; diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index fcd3fbfa5..90445cbab 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -724,6 +724,9 @@ catalog! { SetEnhancements => "Enhancements (non-accuracy)", Some("Mejoras (ajenas a la precisión)"); SetDisableSpriteLimit => "Disable 8-sprite-per-scanline limit (reduces flicker)", Some("Desactivar el límite de 8 sprites por línea (reduce el parpadeo)"); SetOverclock => "Overclock (extra scanlines)", Some("Overclock (líneas extra)"); + SetCpuOverclock => "CPU overclock", Some("Overclock de la CPU"); + SetCpuOverclockOff => "Off (x1)", Some("Desactivado (x1)"); + SetEnhCpuOverclockNote => "Runs the game's CPU 2 to 4 times faster against the same picture and sound, which removes slowdown. Movies record it and replay with it; netplay players must all use the same setting.", Some("Ejecuta la CPU del juego de 2 a 4 veces más rápido con la misma imagen y el mismo sonido, lo que elimina la ralentización. Las películas lo graban y lo reproducen; en el juego en red todos los jugadores deben usar el mismo ajuste."); SetMaxRewind => "Max rewind (seconds)", Some("Rebobinado máximo (segundos)"); SetMaxRewindNote => "(also in Rewind; restart to resize the buffer)", Some("(también en Rebobinado; reinicia para redimensionar el búfer)"); SetConfirmReset => "Confirm reset {0}?", Some("¿Confirmar restablecimiento de {0}?"); @@ -749,7 +752,7 @@ catalog! { SetFamicomConsoleHover => "Model the Famicom's reset wiring instead of the front-loading NES's: the PPU is never held in reset, so it is past its ~29,658-cycle warm-up when the game starts, and the Reset button reaches only the CPU. Some Famicom carts (the 999-in-1 multicart) need it. Takes full effect from the next power cycle or ROM load. Off is byte-identical to today's core.", Some("Modela el cableado de reinicio de la Famicom en lugar del de la NES de carga frontal: la PPU nunca se mantiene en reinicio, así que ya ha terminado su calentamiento de ~29.658 ciclos cuando empieza el juego, y el botón Reset solo llega a la CPU. Algunos cartuchos de Famicom (el multicartucho 999-in-1) lo necesitan. Surte pleno efecto desde el siguiente apagado y encendido o carga de ROM. Desactivado es idéntico byte a byte al núcleo actual."); SetFastDotPathHover =>"Run the specialized straight-line handler for undisturbed visible background dots. Emits the identical frame either way (verified bit-for-bit every frame) and is ~11% faster on rendering-heavy games. Leave on unless you are diagnosing a suspected PPU difference.", Some("Ejecuta el manejador especializado en línea recta para los puntos de fondo visibles sin alteraciones. Produce el mismo fotograma en ambos casos (verificado bit a bit en cada fotograma) y es ~11% más rápido en juegos con mucho renderizado. Déjalo activado salvo que estés diagnosticando una posible diferencia de la PPU."); SetEnhancementsNote => "Off-by-default enhancement modes. These are NEVER applied while accuracy tests / TAS replay / netplay run.", Some("Modos de mejora desactivados por defecto. NUNCA se aplican durante pruebas de precisión / reproducción de TAS / juego en red."); - SetEnhSpriteInert => "Experimental: staged for the v2.0 core pass (currently inert).", Some("Experimental: preparado para la fase del núcleo v2.0 (actualmente sin efecto)."); + SetEnhSpriteInert => "Draws the sprites a scanline drops past eight, behind the eight the console shows. The game sees nothing different. Not on MMC2, MMC4 or a few boards whose pattern reads change the cartridge. Movies record it; netplay players must match.", Some("Dibuja los sprites que una línea descarta a partir del octavo, detrás de los ocho que muestra la consola. El juego no nota ninguna diferencia. No funciona en MMC2, MMC4 ni en algunas placas cuyas lecturas de patrones cambian el cartucho. Las películas lo graban; en el juego en red todos los jugadores deben coincidir."); SetEnhOverclockNote => "Adds idle scanlines after the visible frame to reduce slowdown in some games. Changes timing, so it is ignored while recording or playing a movie and during netplay.", Some("Agrega líneas de barrido inactivas después del fotograma visible para reducir la ralentización en algunos juegos. Cambia la temporización, por lo que se ignora al grabar o reproducir una película y durante el juego en red."); } diff --git a/crates/rustynes-mappers/src/m009_mmc2.rs b/crates/rustynes-mappers/src/m009_mmc2.rs index 606c42dc8..2ca72d159 100644 --- a/crates/rustynes-mappers/src/m009_mmc2.rs +++ b/crates/rustynes-mappers/src/m009_mmc2.rs @@ -173,6 +173,12 @@ impl Mmc2 { } impl Mapper for Mmc2 { + /// v3.1.0: not pure -- a read of tile $FD/$FE switches its CHR latch, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + // v2.8.0 Phase 4 — no per-cycle hooks (no IRQ, no audio): the bus // skips all four per-CPU-cycle dispatches for this board. fn caps(&self) -> MapperCaps { diff --git a/crates/rustynes-mappers/src/m010_mmc4.rs b/crates/rustynes-mappers/src/m010_mmc4.rs index 4cc8e5532..7a0a771ae 100644 --- a/crates/rustynes-mappers/src/m010_mmc4.rs +++ b/crates/rustynes-mappers/src/m010_mmc4.rs @@ -166,6 +166,12 @@ impl Mmc4 { } impl Mapper for Mmc4 { + /// v3.1.0: not pure -- a read of tile $FD/$FE switches its CHR latch, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + fn sram(&self) -> &[u8] { &self.prg_ram } diff --git a/crates/rustynes-mappers/src/m035_jy_asic.rs b/crates/rustynes-mappers/src/m035_jy_asic.rs index ed6ab0153..0f0c73a00 100644 --- a/crates/rustynes-mappers/src/m035_jy_asic.rs +++ b/crates/rustynes-mappers/src/m035_jy_asic.rs @@ -580,6 +580,12 @@ impl JyAsic { } impl Mapper for JyAsic { + /// v3.1.0: not pure -- PPU reads clock its IRQ counter, and mapper 209 latches CHR on reads, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + // CPU-cycle hook (for the CPU-clock IRQ source) + IRQ source. No audio. fn caps(&self) -> MapperCaps { MapperCaps::CYCLE_IRQ diff --git a/crates/rustynes-mappers/src/m096_bandai96.rs b/crates/rustynes-mappers/src/m096_bandai96.rs index 8004e11fa..e059a9926 100644 --- a/crates/rustynes-mappers/src/m096_bandai96.rs +++ b/crates/rustynes-mappers/src/m096_bandai96.rs @@ -115,6 +115,12 @@ impl Bandai96 { } impl Mapper for Bandai96 { + /// v3.1.0: not pure -- its inner CHR bank follows the last PPU address read, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + fn caps(&self) -> MapperCaps { MapperCaps::NONE } diff --git a/crates/rustynes-mappers/src/m163_nanjing.rs b/crates/rustynes-mappers/src/m163_nanjing.rs index 17e98234f..b6925e3e1 100644 --- a/crates/rustynes-mappers/src/m163_nanjing.rs +++ b/crates/rustynes-mappers/src/m163_nanjing.rs @@ -125,6 +125,12 @@ impl Nanjing163 { } impl Mapper for Nanjing163 { + /// v3.1.0: not pure -- it latches PPU A13 from reads, so the PPU's display-only + /// "disable sprite limit" reads are not made on this board. + fn chr_reads_are_pure(&self) -> bool { + false + } + fn sram(&self) -> &[u8] { &self.wram } diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 9667074a1..99c63a6d2 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -325,6 +325,22 @@ pub trait Mapper: Send { None } + /// v3.1.0 (`T-SPRITE-LIMIT`) — whether [`Self::ppu_read`] and + /// [`Self::ppu_read_sprite`] on `$0000-$1FFF` change nothing but return a + /// byte. `true` (the default) lets the PPU's "disable sprite limit" option + /// make extra, display-only pattern reads on this board. + /// + /// Override to `false` for any board whose CHR read has an effect: a latch + /// that switches banks on a tile (MMC2, MMC4), an IRQ counter clocked by + /// reads (the J.Y. ASIC), or address bits latched from the read (Bandai + /// 96, Nanjing 163). `every_board_that_claims_pure_chr_reads_has_them` + /// checks the claim against `save_state` for every mapper id, so a new + /// impure board that keeps the default fails a test rather than letting + /// the option change emulation. + fn chr_reads_are_pure(&self) -> bool { + true + } + /// Write a byte to the PPU address space `$0000-$3FFF`. fn ppu_write(&mut self, addr: u16, value: u8); @@ -806,6 +822,58 @@ mod caps_tests { mapper.caps() } + /// v3.1.0 (`T-SPRITE-LIMIT`): every board that reports + /// `chr_reads_are_pure` really has pure CHR reads. The PPU's "disable + /// sprite limit" option makes extra pattern reads exactly where this is + /// `true`, so a wrong `true` would let a display option change emulation. + /// + /// For every mapper id the parser builds from a synthetic ROM (iNES ids + /// 0-255 and NES 2.0 ids 256-4095), read all of CHR through both entry + /// points and require `save_state` to be unchanged. The five boards that + /// report `false` were found by a scan of every `ppu_read` body for writes + /// to `self` (v3.1.0); this test is what keeps a sixth from keeping the + /// default. It cannot see state a board leaves out of `save_state`, which + /// would already be a save-state defect of its own. + #[test] + fn every_board_that_claims_pure_chr_reads_has_them() { + fn nes2_rom(id: u16) -> Vec { + let [lo, hi] = id.to_le_bytes(); + let mut rom = synth_rom(lo); + rom[7] = (rom[7] & 0xF0) | 0x08; // NES 2.0 identifier + rom[8] = hi & 0x0F; // mapper bits 8-11 + rom + } + let mut checked = 0usize; + let mut impure = Vec::new(); + for id in 0u16..4096 { + let rom = u8::try_from(id).map_or_else(|_| nes2_rom(id), synth_rom); + let Ok((_cart, mut mapper)) = parse(&rom) else { + continue; + }; + if !mapper.chr_reads_are_pure() { + impure.push(id); + continue; + } + let before = mapper.save_state(); + for addr in 0..0x2000u16 { + let _ = mapper.ppu_read(addr); + let _ = mapper.ppu_read_sprite(addr); + } + assert_eq!( + before, + mapper.save_state(), + "mapper {id} reports pure CHR reads, but reading CHR changed its state" + ); + checked += 1; + } + assert!(checked > 150, "only {checked} boards were checked"); + assert_eq!( + impure, + vec![9, 10, 35, 90, 96, 163, 209, 211], + "the impure set moved" + ); + } + /// v2.8.0 Phase 4 — the capability-flag contract for the key families. /// A flag may be `false` ONLY when the mapper does not override the /// corresponding default no-op; these spot checks pin the mechanical diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs index d9a2576db..f035657dd 100644 --- a/crates/rustynes-netplay/src/message.rs +++ b/crates/rustynes-netplay/src/message.rs @@ -176,9 +176,12 @@ impl SessionIdentity { Err(why) => SyncVerdict::Refuse(why), } } else if NetMessage::OLDER_SYNC_MAGICS.contains(&magic) { + // Protocol 6 carries its epoch (same layout as ours), so the reason + // can name it; protocols 4 and 5 have none. + let theirs = (magic == NetMessage::OLDER_SYNC_MAGICS[2]).then_some(peer.epoch); SyncVerdict::Refuse(IdentityMismatch::Emulator { ours: self.epoch, - theirs: None, + theirs, }) } else { SyncVerdict::Ignore @@ -216,8 +219,18 @@ impl SessionIdentity { /// ([`SessionIdentity::check_sync`]). A v5 peer ignores our magic, as v4 /// ignored v5's, so on its side the session still times out. /// +/// `7` (v3.1.0): the `Sync` LAYOUT is protocol 6's (72 bytes), under the +/// magic `"RNE7"`. What changed is the configuration hash's input: the +/// [`rustynes_core::HardwareOptions`] encoding gained the CPU-multiplier +/// overclock and the sprite-limit option (`T-CPU-OVERCLOCK`, +/// `T-SPRITE-LIMIT`), so two peers with identical settings on v3.0.x and +/// v3.1.0 hash them differently. Without a new magic a v3.0.x peer would be +/// refused as "settings differ", a wrong reason; under `"RNE7"` its `"RNE6"` +/// is one of [`NetMessage::OLDER_SYNC_MAGICS`] and it is refused as another +/// emulator version, naming its epoch (protocol 6 carries it). +/// /// [`from_bytes`]: NetMessage::from_bytes -pub const PROTOCOL_VERSION: u32 = 6; +pub const PROTOCOL_VERSION: u32 = 7; /// Messages exchanged between two peers. /// @@ -323,13 +336,13 @@ impl NetMessage { /// consider the session synced while the v5 side waited for a reply it /// refuses. Every version compares the magic, so a changed one is /// rejected on both sides. - pub const SYNC_MAGIC: u32 = 0x524E_4536; // "RNE6" + pub const SYNC_MAGIC: u32 = 0x524E_4537; // "RNE7" /// `RustyNES`'s own earlier `Sync` magics: protocol 4 (`"RNES"`, v2.5.x to - /// v2.9.7) and protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9). Recognised so a - /// peer on an older version is refused with a reason - /// ([`SessionIdentity::check_sync`]). - pub const OLDER_SYNC_MAGICS: [u32; 2] = [0x524E_4553, 0x524E_4535]; + /// v2.9.7), protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9) and protocol 6 + /// (`"RNE6"`, v3.0.0 and v3.0.1). Recognised so a peer on an older version + /// is refused with a reason ([`SessionIdentity::check_sync`]). + pub const OLDER_SYNC_MAGICS: [u32; 3] = [0x524E_4553, 0x524E_4535, 0x524E_4536]; // Tag bytes for the hand-rolled encoding. const TAG_INPUT: u8 = 0; @@ -473,9 +486,10 @@ impl NetMessage { Self::TAG_SYNC => { let magic = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?); match rest.len() { - // Protocol 6: magic + epoch + two hashes. Exactly that - // length: a longer payload is a later protocol's, not this - // one's with junk on the end. + // Protocols 6 and 7: magic + epoch + two hashes. Exactly + // that length: a longer payload is a later protocol's, not + // this one's with junk on the end. Which protocol it is + // (`"RNE7"` ours, `"RNE6"` v3.0.x) is `check_sync`'s call. 72 => { let epoch = u32::from_le_bytes(rest.get(4..8)?.try_into().ok()?); let rom_hash: [u8; 32] = rest.get(8..40)?.try_into().ok()?; @@ -699,6 +713,37 @@ mod tests { assert_eq!(ours.check_sync(0xDEAD_BEEF, &ours), SyncVerdict::Ignore); } + /// v3.1.0 (protocol 7) — a v3.0.x peer's `"RNE6"` `Sync` has OUR length + /// (72 bytes) but hashes its options with the v3.0.x encoding, so even + /// with identical settings its configuration hash differs from ours. It + /// must be refused as another emulator version, naming its epoch (the + /// layout carries one), not as a settings mismatch and not ignored. + #[test] + fn a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch() { + let ours = SessionIdentity::new([7u8; 32], [9u8; 32]); + let theirs = SessionIdentity { + epoch: 2, + rom_hash: [7u8; 32], + config_hash: [1u8; 32], + }; + let bytes = NetMessage::Sync { + magic: NetMessage::OLDER_SYNC_MAGICS[2], + identity: theirs, + } + .to_bytes(); + let Some(NetMessage::Sync { magic, identity }) = NetMessage::from_bytes(&bytes) else { + panic!("a protocol-6 Sync decodes"); + }; + assert_eq!( + ours.check_sync(magic, &identity), + SyncVerdict::Refuse(IdentityMismatch::Emulator { + ours: ours.epoch, + theirs: Some(2), + }) + ); + assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::OLDER_SYNC_MAGICS[2]); + } + #[test] fn roster_roundtrips_v4_and_v6() { use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; diff --git a/crates/rustynes-ppu/src/bus.rs b/crates/rustynes-ppu/src/bus.rs index bd6ed93b2..8cfafb876 100644 --- a/crates/rustynes-ppu/src/bus.rs +++ b/crates/rustynes-ppu/src/bus.rs @@ -42,6 +42,16 @@ pub trait PpuBus { None } + /// v3.1.0 (`T-SPRITE-LIMIT`) — whether a CHR read through + /// [`Self::ppu_read`] / [`Self::ppu_read_sprite`] has no effect beyond + /// returning the byte. The "disable sprite limit" option makes extra, + /// display-only pattern reads, and only where this is `true`, so the + /// option can never change emulation. Default `true`; the core forwards the + /// mapper's answer (`Mapper::chr_reads_are_pure`). + fn chr_reads_are_pure(&self) -> bool { + true + } + /// Write a byte at `addr`. fn ppu_write(&mut self, addr: u16, value: u8); diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 4e405aa18..e542ea3a2 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -35,6 +35,11 @@ pub const FRAMEBUFFER_LEN: usize = SCREEN_WIDTH * SCREEN_HEIGHT * 4; /// [`Ppu::index_framebuffer`] (one `u16` per pixel). pub const FRAMEBUFFER_PIXELS: usize = SCREEN_WIDTH * SCREEN_HEIGHT; +/// v3.1.0 (`T-SPRITE-LIMIT`) — the most sprites the "disable sprite limit" +/// option can add to one scanline: all 64 OAM entries minus the eight the +/// hardware draws. +pub const MAX_EXTRA_SPRITES: usize = 64 - 8; + /// v1.2.0 beta.2 (Workstream C3) — per-pixel HD-pack tile-source record. /// /// One entry per visible pixel (parallel to [`Ppu::index_framebuffer`]), @@ -1105,10 +1110,30 @@ pub struct Ppu { /// without altering the visible image. **Off by default (`0`)**; the /// `advance_dot` insertion path is entirely guarded by `extra_scanlines != 0`, /// so at the default this field changes nothing and the frame is - /// byte-identical to stock. Distinct from the CPU-multiplier overclock (a - /// v2.0 timebase item). A frontend config knob, NOT part of the save-state - /// (re-applied by the frontend on restore, like `region` / `active_palette`). + /// byte-identical to stock. Distinct from the CPU-multiplier overclock + /// (`Nes::set_cpu_overclock`, v3.1.0, in the bus). A frontend config knob, + /// NOT part of the save-state (re-applied by the frontend on restore, like + /// `region` / `active_palette`). pub(crate) extra_scanlines: u16, + /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02): draw the sprites beyond the eighth on + /// a scanline. **Render-only**: sprite evaluation, secondary OAM, the + /// overflow flag, sprite-0 hit and every real sprite fetch (with its A12 + /// edges) are untouched. The extra sprites' patterns are read after the + /// eight real fetches, through [`PpuBus::chr_reads_are_pure`] boards only, + /// with no A12 notification, and they draw behind all eight hardware + /// sprites (a higher OAM index is a lower priority). Off by default; + /// configuration, carried across a power cycle by + /// [`Self::adopt_settings_from`] and in movies / netplay by the core's + /// `HardwareOptions`. + pub(crate) sprite_limit_disabled: bool, + /// v3.1.0 — the extra sprites fetched for the next scanline (snapshot v13): + /// how many, and for each the h-flip-applied pattern bytes, attributes and + /// X. Always 0 while `sprite_limit_disabled` is off. + pub(crate) spr_extra_count: u8, + pub(crate) spr_extra_lo: [u8; MAX_EXTRA_SPRITES], + pub(crate) spr_extra_hi: [u8; MAX_EXTRA_SPRITES], + pub(crate) spr_extra_attr: [u8; MAX_EXTRA_SPRITES], + pub(crate) spr_extra_x: [u8; MAX_EXTRA_SPRITES], /// v1.7.0 F3 — countdown of extra blank scanlines remaining for the CURRENT /// frame's vblank insertion. Loaded from [`Self::extra_scanlines`] when the /// PPU reaches the insertion point and decremented one extra line at a time. @@ -1635,6 +1660,12 @@ impl Ppu { dot_counter: 0, frame_ntsc_phase: 0, extra_scanlines: 0, + sprite_limit_disabled: false, + spr_extra_count: 0, + spr_extra_lo: [0; MAX_EXTRA_SPRITES], + spr_extra_hi: [0; MAX_EXTRA_SPRITES], + spr_extra_attr: [0; MAX_EXTRA_SPRITES], + spr_extra_x: [0; MAX_EXTRA_SPRITES], extra_lines_remaining: 0, // v2.2.3 performance pass: promoted to the default (was `false` // through v2.2.2). Byte-identical to the exact path by @@ -1742,6 +1773,30 @@ impl Ppu { self.rebuild_rgba_lut(); } + /// v3.1.0 (`T-SPRITE-LIMIT`) — draw the sprites beyond the eighth on a + /// scanline (`true`), or not (`false`, the default and the hardware). See + /// the field for what stays exact. Turning it off drops any extras already + /// fetched, so the next scanline draws exactly eight. + pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { + self.sprite_limit_disabled = disabled; + if !disabled { + self.spr_extra_count = 0; + } + } + + /// v3.1.0 — whether the sprites beyond the eighth are drawn. + #[must_use] + pub const fn sprite_limit_disabled(&self) -> bool { + self.sprite_limit_disabled + } + + /// v3.1.0 — how many sprites beyond the eighth are fetched for the next + /// scanline (`0` unless [`Self::sprite_limit_disabled`]). + #[must_use] + pub const fn extra_sprite_count(&self) -> u8 { + self.spr_extra_count + } + /// v1.7.0 "Forge" Workstream F3 — set the number of EXTRA blank vblank /// scanlines to insert per frame (the PPU extra-scanlines overclock). /// @@ -1749,7 +1804,7 @@ impl Ppu { /// that never calls this. A non-zero value lengthens vblank by that many /// idle scanlines each frame (more CPU run-time, no visible change), at the /// existing dot resolution. Off by default; a frontend config knob, not part - /// of the save-state. Distinct from the CPU-multiplier overclock (v2.0). + /// of the save-state. Distinct from the CPU-multiplier overclock (v3.1.0). /// /// Changing the count cancels any in-flight insertion for the current /// frame: the per-frame countdown (`extra_lines_remaining`) is @@ -1809,7 +1864,8 @@ impl Ppu { /// # What is carried, and what is not /// /// Carried: [`Self::custom_palette`] (the lookup table is rebuilt to - /// honour it), [`Self::extra_scanlines`], [`Self::fast_dotloop`] and + /// honour it), [`Self::extra_scanlines`], [`Self::sprite_limit_disabled`], + /// [`Self::fast_dotloop`] and /// [`Self::oam_decay_enabled`]. The decay switch goes through /// [`Self::set_oam_decay`], exactly as a host enabling it on a fresh /// console would, so the result is what a fresh boot with the setting @@ -1831,6 +1887,7 @@ impl Ppu { self.custom_palette = prev.custom_palette; self.rebuild_rgba_lut(); self.set_extra_scanlines(prev.extra_scanlines); + self.sprite_limit_disabled = prev.sprite_limit_disabled; self.fast_dotloop = prev.fast_dotloop; self.set_oam_decay(prev.oam_decay_enabled); } @@ -5101,6 +5158,32 @@ impl Ppu { break; } } + // v3.1.0 (`T-SPRITE-LIMIT`): the sprites beyond the eighth, only where + // none of the eight hardware sprites is opaque (they have the higher + // OAM indexes, so the lower priority). Never sprite 0, so never a hit. + // `spr_extra_count` is 0 unless the option is on. + if spr_idx == 0 + && self.spr_extra_count != 0 + && self.mask.contains(PpuMask::SHOW_SPRITE) + && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) + { + for e in 0..usize::from(self.spr_extra_count) { + let off = pixel_x.wrapping_sub(u16::from(self.spr_extra_x[e])); + if off >= 8 { + continue; + } + let bit = 7 - off; + let lo = (self.spr_extra_lo[e] >> bit) & 1; + let hi = (self.spr_extra_hi[e] >> bit) & 1; + let val = (hi << 1) | lo; + if val != 0 { + spr_idx = val; + spr_pal = self.spr_extra_attr[e] & 0x03; + spr_priority_front = (self.spr_extra_attr[e] & 0x20) == 0; + break; + } + } + } // Combine BG + sprite per priority. // @@ -6342,6 +6425,92 @@ impl Ppu { } } // Else: shift regs already cleared in tick_sprite_eval_per_dot. + + // v3.1.0 (`T-SPRITE-LIMIT`): after the eighth REAL fetch, the extra + // sprites for the same line. A no-op unless the option is on. + if slot == 7 { + self.fetch_extra_sprites(bus, next_line, sprite_height); + } + } + + /// v3.1.0 (`T-SPRITE-LIMIT`, FE-02): collect and fetch the sprites beyond + /// the eighth for the next scanline, for display only. + /// + /// Runs once per line, after the eighth real sprite fetch, and only when + /// the option is on, evaluation found eight (so the hardware dropped + /// some), the line is visible, and the board's CHR reads are pure + /// ([`PpuBus::chr_reads_are_pure`]: MMC2 / MMC4 latch on CHR reads, the + /// J.Y. ASIC clocks an IRQ on them, and two boards latch address bits, so + /// on those the option draws eight as stock). The fetch calls neither + /// `observe_a12_addr` nor anything else a mapper can see beyond the read, + /// so A12, mapper IRQs and every emulated byte stay exactly stock. + /// + /// Which sprites: an aligned walk of primary OAM from entry 0, skipping + /// the first eight in range (the ones the hardware draws when evaluation + /// starts at OAMADDR 0, as it does on every normally rendered line). A line + /// whose evaluation starts misaligned (a mid-frame `$2003` write, a test + /// construction) can draw a slightly different set; it is a display + /// enhancement, not hardware behaviour. + fn fetch_extra_sprites(&mut self, bus: &mut B, next_line: i16, height: i16) { + self.spr_extra_count = 0; + if !self.sprite_limit_disabled + || self.spr_count < 8 + || !(0..240).contains(&self.scanline) + || !bus.chr_reads_are_pure() + { + return; + } + let mut in_range = 0usize; + for n in 0..64usize { + let y = i16::from(self.oam[n * 4]); + let row = next_line.wrapping_sub(y); + if row < 0 || row >= height { + continue; + } + in_range += 1; + if in_range <= 8 { + continue; + } + let count = usize::from(self.spr_extra_count); + if count == MAX_EXTRA_SPRITES { + break; + } + let tile = self.oam[n * 4 + 1]; + let attr = self.oam[n * 4 + 2] & 0xE3; + let x = self.oam[n * 4 + 3]; + let flip_v = attr & 0x80 != 0; + #[allow(clippy::cast_sign_loss)] // `row` is in 0..height, checked above + let mut r = row as u16; + let (table, tile_idx) = if height == 16 { + if flip_v { + r = 15 - r; + } + let base = tile & 0xFE; + let idx = if r >= 8 { base.wrapping_add(1) } else { base }; + r &= 7; + (u16::from(tile & 0x01) << 12, idx) + } else { + if flip_v { + r = 7 - r; + } + ( + u16::from(self.ctrl.contains(PpuCtrl::SPRITE_PATTERN_HIGH)) << 12, + tile, + ) + }; + let addr = table | (u16::from(tile_idx) << 4) | r; + let mut lo = bus.ppu_read_sprite(addr); + let mut hi = bus.ppu_read_sprite(addr | 0x08); + if attr & 0x40 != 0 { + lo = reverse_bits(lo); + hi = reverse_bits(hi); + } + self.spr_extra_lo[count] = lo; + self.spr_extra_hi[count] = hi; + self.spr_extra_attr[count] = attr; + self.spr_extra_x[count] = x; + self.spr_extra_count += 1; + } } fn advance_dot(&mut self) { diff --git a/crates/rustynes-ppu/src/snapshot.rs b/crates/rustynes-ppu/src/snapshot.rs index 3a592309f..5d845a6e1 100644 --- a/crates/rustynes-ppu/src/snapshot.rs +++ b/crates/rustynes-ppu/src/snapshot.rs @@ -163,7 +163,15 @@ use crate::registers::{PpuCtrl, PpuMask, PpuStatus}; /// `spr_rearm_deferred` it is live only across the frame boundary, where /// run-ahead and save states snapshot; dropping it would let a restored /// frame raise A12 at a dot 0 the skip removed, which an MMC3 counts. -pub const PPU_SNAPSHOT_VERSION: u8 = 12; +/// - v13 (v3.1.0, `T-SPRITE-LIMIT`): appends the extra sprites the "disable +/// sprite limit" option fetched for the next scanline: a count (1 byte, +/// `0..=MAX_EXTRA_SPRITES`) and four 56-byte arrays (pattern low, pattern +/// high, attributes, X). Render-only state, but it decides the next +/// scanline's picture, and a snapshot can fall between the fetch (dots +/// 257-320) and that line, so it is carried rather than dropped, the rule +/// `snapshot_schema_audit.rs` exists for. All zero while the option is off. +/// No upconvert: v3.1.0 states are refused by BUS section 3 regardless. +pub const PPU_SNAPSHOT_VERSION: u8 = 13; /// v2.3.3 — high bit of the version byte, marking a **slim** snapshot: every /// field except the 245,760-byte framebuffer. @@ -628,6 +636,13 @@ impl Ppu { // the skip, consumed at that dot), live across the frame boundary. w.u8(u8::from(self.dot0_replaced)); + // v13 tail — the sprite-limit option's extra sprites for the next line. + w.u8(self.spr_extra_count); + w.bytes(&self.spr_extra_lo); + w.bytes(&self.spr_extra_hi); + w.bytes(&self.spr_extra_attr); + w.bytes(&self.spr_extra_x); + w.buf } @@ -870,6 +885,17 @@ impl Ppu { // v12: the odd-frame skip replaced scanline 0's dot 0. self.dot0_replaced = r.u8()? != 0; + // v13: the sprite-limit option's extra sprites for the next line. + self.spr_extra_count = bounded( + "spr_extra_count", + r.u8()?, + u8::try_from(crate::ppu::MAX_EXTRA_SPRITES).unwrap_or(u8::MAX), + )?; + r.bytes_into(&mut self.spr_extra_lo)?; + r.bytes_into(&mut self.spr_extra_hi)?; + r.bytes_into(&mut self.spr_extra_attr)?; + r.bytes_into(&mut self.spr_extra_x)?; + // Derived-cache fixup: the scanline-classification cache // is a pure function of `scanline` + `region`, so it is recomputed rather // than carried. Resetting the key to the `Ppu::new` sentinel forces the diff --git a/crates/rustynes-test-harness/src/lib.rs b/crates/rustynes-test-harness/src/lib.rs index 5bcdeefcc..c81989bd2 100644 --- a/crates/rustynes-test-harness/src/lib.rs +++ b/crates/rustynes-test-harness/src/lib.rs @@ -29,7 +29,8 @@ pub mod coverage; pub use blargg::{BlarggBus, BlarggResult, run_blargg_until_complete}; pub use nes_runner::{ CodeTestResult, CodeVerdict, NesTestResult, ScreenTestResult, ScreenVerdict, run_nes_blargg, - run_nes_blargg_pal, run_nes_blargg_reset, run_nes_result_code, run_nes_screen, + run_nes_blargg_pal, run_nes_blargg_reset, run_nes_blargg_with, run_nes_result_code, + run_nes_screen, }; pub use nestest::{LogLine, NestestBus, NestestRunner, format_log_line, parse_log_line}; diff --git a/crates/rustynes-test-harness/src/nes_runner.rs b/crates/rustynes-test-harness/src/nes_runner.rs index 876070413..8e23e412f 100644 --- a/crates/rustynes-test-harness/src/nes_runner.rs +++ b/crates/rustynes-test-harness/src/nes_runner.rs @@ -54,7 +54,23 @@ fn read_message(nes: &mut Nes) -> String { /// /// Returns the underlying [`RomError`] if the bytes don't parse. pub fn run_nes_blargg(rom_bytes: &[u8], max_frames: u64) -> Result { - run_nes_blargg_inner(rom_bytes, max_frames, false) + run_nes_blargg_inner(rom_bytes, max_frames, false, &|_| {}) +} + +/// [`run_nes_blargg`] with `configure` applied before the first frame. +/// +/// v3.1.0: for running a ROM under an option, such as the sprite-limit option +/// against the sprite-overflow suite. +/// +/// # Errors +/// +/// Returns the underlying [`RomError`] if the bytes don't parse. +pub fn run_nes_blargg_with( + rom_bytes: &[u8], + max_frames: u64, + configure: &dyn Fn(&mut Nes), +) -> Result { + run_nes_blargg_inner(rom_bytes, max_frames, false, configure) } /// Run a blargg-style ROM but **force the PAL region** before booting. @@ -75,7 +91,7 @@ pub fn run_nes_blargg(rom_bytes: &[u8], max_frames: u64) -> Result Result { - run_nes_blargg_inner(rom_bytes, max_frames, true) + run_nes_blargg_inner(rom_bytes, max_frames, true, &|_| {}) } /// Rewrite a throwaway copy of `rom_bytes` to force **PAL region** selection. @@ -281,12 +297,14 @@ fn run_nes_blargg_inner( rom_bytes: &[u8], max_frames: u64, force_pal: bool, + configure: &dyn Fn(&mut Nes), ) -> Result { // `owned` holds the PAL-stamped copy (if any) for the borrow's lifetime; // when absent (NTSC, or a sub-16-byte buffer) we run the original bytes. let owned = force_pal.then(|| pal_forced_copy(rom_bytes)).flatten(); let bytes: &[u8] = owned.as_deref().unwrap_or(rom_bytes); let mut nes = Nes::from_rom(bytes)?; + configure(&mut nes); let magic = [b'D', b'E', b'B', 0]; let mut started = false; let mut frames = 0u64; diff --git a/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs new file mode 100644 index 000000000..301a43a9c --- /dev/null +++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs @@ -0,0 +1,201 @@ +//! `T-CPU-OVERCLOCK` (v3.1.0, FE-01, D22): the CPU-multiplier overclock. +//! +//! `Nes::set_cpu_overclock(k)` runs the CPU `k` times faster against an +//! unchanged PPU, by dividing the region's master-clock CPU divider, while the +//! APU, the DMC and every mapper's CPU-cycle hook stay at the stock rate, so +//! a game gets `k` times the CPU time per frame with its pitch, its tempo and +//! its cycle-timed mapper IRQs unchanged. Off by default (`1`), and `1` is +//! byte-identical to stock (pinned separately by the epoch fingerprint gate, +//! whose seven ROMs all run at `1`). +//! +//! What these tests pin: +//! * the default and the clamp (`1..=4`); +//! * the multiplier reaches the core: CPU cycles per frame scale by `k`; +//! * the APU stays at the stock rate: the same number of audio samples per +//! frame at every `k`; +//! * it rides in [`HardwareOptions`] (capture, encode, decode, and a named +//! difference), which is what refuses a movie or a netplay peer recorded +//! with another value; +//! * a snapshot taken mid-run under the overclock restores to the same +//! continuation, which is what run-ahead and rewind rely on. + +#![cfg(feature = "test-roms")] + +mod common; + +use std::fs; + +use common::{fnv1a64, rom_path}; +use rustynes_core::save_state::{BinReader, BinWriter}; +use rustynes_core::{HardwareOptions, Nes}; + +const ROM: &str = "nes-test-roms/ny2011/ny2011.nes"; +/// Audible from about frame 120 (ny2011 is silent for its first 300 frames, +/// which made the snapshot test below blind to the APU's phase: a restore +/// that dropped the stock-rate position passed it). +const AUDIBLE_ROM: &str = "nes-test-roms/apu_mixer/square.nes"; + +fn boot_rom(rom: &str) -> Nes { + let path = rom_path(rom); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + Nes::from_rom(&bytes).unwrap_or_else(|e| panic!("parse {rom}: {e}")) +} + +fn boot() -> Nes { + boot_rom(ROM) +} + +/// CPU cycles and audio samples over `frames` frames at overclock `k`. +fn measure(k: u8, frames: u32) -> (u64, usize) { + let mut nes = boot(); + nes.set_cpu_overclock(k); + // Settle past power-on first, so the frame boundary is a steady one. + for _ in 0..30 { + nes.run_frame(); + let _ = nes.drain_audio(); + } + let start = nes.cycle(); + let mut samples = 0; + for _ in 0..frames { + nes.run_frame(); + samples += nes.drain_audio().len(); + } + (nes.cycle() - start, samples) +} + +#[test] +fn the_default_is_stock_and_the_multiplier_is_clamped() { + let mut nes = boot(); + assert_eq!(nes.cpu_overclock(), 1, "off by default"); + nes.set_cpu_overclock(0); + assert_eq!(nes.cpu_overclock(), 1, "0 means stock, not a stopped CPU"); + nes.set_cpu_overclock(9); + assert_eq!( + nes.cpu_overclock(), + rustynes_core::MAX_CPU_OVERCLOCK, + "clamped to the maximum" + ); +} + +#[test] +fn cpu_cycles_per_frame_scale_with_the_multiplier() { + let frames = 60; + let (stock, _) = measure(1, frames); + for k in 2..=rustynes_core::MAX_CPU_OVERCLOCK { + let (cycles, _) = measure(k, frames); + #[allow(clippy::cast_precision_loss)] + let ratio = cycles as f64 / stock as f64; + let want = f64::from(k); + assert!( + (ratio - want).abs() < 0.01, + "x{k}: {cycles} CPU cycles over {frames} frames against {stock} stock \ + is a ratio of {ratio:.4}, not {want}" + ); + } +} + +#[test] +fn the_apu_stays_at_the_stock_rate() { + let frames = 60; + let (_, stock) = measure(1, frames); + for k in 2..=rustynes_core::MAX_CPU_OVERCLOCK { + let (_, samples) = measure(k, frames); + // One sample of slack per frame for where the frame boundary falls + // inside a resampler step. + assert!( + samples.abs_diff(stock) <= frames as usize, + "x{k}: {samples} audio samples over {frames} frames against {stock} \ + stock; an APU clocked with the CPU would produce about {k} times as many" + ); + } +} + +#[test] +fn the_multiplier_rides_in_hardware_options() { + let mut nes = boot(); + nes.set_cpu_overclock(3); + let opts = HardwareOptions::capture(&nes); + assert_eq!(opts.cpu_overclock, 3); + + let mut w = BinWriter::with_capacity(64); + opts.write_to(&mut w); + let bytes = w.into_vec(); + let back = HardwareOptions::read_from(&mut BinReader::new(&bytes)).expect("decodes"); + assert_eq!(back, opts, "encode/decode round trip"); + + assert_eq!(HardwareOptions::default().cpu_overclock, 1); + // Against the same machine captured at stock, so only the multiplier + // differs (the default's `vs_ppu_type` is `None`, a capture's is not). + let stock = HardwareOptions::capture(&boot()); + assert_eq!(stock.cpu_overclock, 1); + assert_eq!( + stock.differences(&opts), + vec!["CPU overclock"], + "a movie or peer on another value is refused with the option named" + ); + + let mut other = boot(); + opts.apply_live(&mut other).expect("apply"); + assert_eq!(other.cpu_overclock(), 3, "apply reaches the core"); +} + +#[test] +fn an_out_of_range_multiplier_in_a_file_is_refused() { + let mut opts = HardwareOptions::default(); + opts.cpu_overclock = 2; + let mut w = BinWriter::with_capacity(64); + opts.write_to(&mut w); + let mut bytes = w.into_vec(); + let at = bytes + .iter() + .position(|&b| b == 2) + .expect("the multiplier byte is in the encoding"); + bytes[at] = rustynes_core::MAX_CPU_OVERCLOCK + 1; + assert!( + HardwareOptions::read_from(&mut BinReader::new(&bytes)).is_err(), + "a multiplier the core would clamp cannot replay as written" + ); +} + +#[test] +fn a_snapshot_under_the_overclock_restores_to_the_same_continuation() { + let mut nes = boot_rom(AUDIBLE_ROM); + nes.set_cpu_overclock(3); + for _ in 0..150 { + nes.run_frame(); + } + // Audio already produced is host-side output, not machine state: drain + // it so both runs below start from an empty buffer. + let _ = nes.drain_audio(); + let snap = nes.snapshot(); + let run = |nes: &mut Nes| { + let mut h = Vec::new(); + for _ in 0..20 { + nes.run_frame(); + h.extend_from_slice(&fnv1a64(nes.framebuffer()).to_le_bytes()); + for s in nes.drain_audio() { + h.extend_from_slice(&s.to_le_bytes()); + } + } + (fnv1a64(&h), nes.cycle()) + }; + // The window must be audible, or the APU's phase is not observed. + { + let mut probe = boot_rom(AUDIBLE_ROM); + probe.set_cpu_overclock(3); + for _ in 0..150 { + probe.run_frame(); + } + let _ = probe.drain_audio(); + probe.run_frame(); + let peak = probe.drain_audio().iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.01, "the snapshot window is silent (peak {peak})"); + } + let first = run(&mut nes); + nes.restore(&snap).expect("own snapshot restores"); + let second = run(&mut nes); + assert_eq!( + first, second, + "a restore under the overclock must replay the same frames, samples and cycles" + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index f5f2d114f..f542ae3f4 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -145,6 +145,12 @@ const CHIPS: &[Chip] = &[ "config: the overclock amount; the in-flight countdown \ `extra_lines_remaining` IS serialized (v4 tail)", ), + ( + "sprite_limit_disabled", + "config: the v3.1.0 sprite-limit option, re-applied by the host, carried in \ + `HardwareOptions` and across a power cycle; the extra sprites it fetched \ + for the next line (`spr_extra_*`) ARE serialized (v13 tail)", + ), ( "fast_dotloop", "config: runtime performance knob (v2.1.8 A1); selects a code path, holds no state", @@ -480,6 +486,22 @@ const CHIPS: &[Chip] = &[ "ppu_div_cached", "derived: the region's PPU master-clock divider, cached at construction", ), + ( + "cpu_div_effective", + "derived: `cpu_div_cached / cpu_overclock`, recomputed when the overclock is set", + ), + ( + "stock_step", + "transient: set in `cpu_clock` and read by `cpu_clock_apu_dmc` within ONE CPU \ + cycle; a snapshot falls between cycles, and restore sets it `true`. The \ + overclock's persistent position (`overclock_debt`, `apu_cycle`) IS \ + serialized (BUS version 3)", + ), + ( + "cpu_overclock", + "config: the v3.1.0 CPU-multiplier overclock, re-applied by the host and \ + carried in `HardwareOptions`, like the extra-scanline overclock", + ), // --- Opt-in hardware knobs, re-applied by the host on load. ( "power_on_ram", diff --git a/crates/rustynes-test-harness/tests/sprite_limit.rs b/crates/rustynes-test-harness/tests/sprite_limit.rs new file mode 100644 index 000000000..82e91919d --- /dev/null +++ b/crates/rustynes-test-harness/tests/sprite_limit.rs @@ -0,0 +1,211 @@ +//! `T-SPRITE-LIMIT` (v3.1.0, FE-02, D22): "disable sprite limit". +//! +//! `Nes::set_sprite_limit_disabled(true)` draws the sprites beyond the eighth +//! on a scanline. It is render-only, and these tests pin that both ways: +//! +//! * **the game sees nothing.** With the option on, every CPU cycle, every +//! work-RAM byte and every audio sample matches the option off, frame by +//! frame, and blargg's five sprite-overflow ROMs (which read the overflow +//! flag that evaluation sets) still pass; +//! * **the picture does change.** On a ROM built here that keeps sixteen +//! opaque sprites on one line, the framebuffers differ: without that the +//! first test would pass for an option that does nothing. +//! +//! No committed ROM could serve as that stimulus, measured: `spritecans` +//! never puts more than seven sprites on a line, and `NEStress` crowds 62 +//! onto lines 1-8 but with a transparent tile, so both drew identically with +//! the option on (and the extra sprites WERE fetched on `NEStress`, 54 of +//! them, which is how the blind spot was told apart from a broken feature). +//! +//! Plus the carriage in `HardwareOptions` and a mid-frame snapshot round trip +//! (the extra sprites for the next line are PPU snapshot v13 state). + +#![cfg(feature = "test-roms")] + +mod common; + +use std::fs; + +use common::{fnv1a64, rom_path}; +use rustynes_core::{HardwareOptions, Nes}; + +/// A 16 KiB NROM program that keeps sixteen opaque 8x8 sprites on scanline +/// 101 (`Y = 100`, `X = 0, 16, .., 240`, tile 1, palette `$3F11 = $30`), +/// re-sent by OAM DMA every frame with sprites shown and the background off. +/// The hardware draws the left eight; the option adds the right eight. +fn crowded_rom() -> Vec { + #[rustfmt::skip] + let code: [u8; 99] = [ + 0x78, 0xD8, 0xA2, 0xFF, 0x9A, // SEI CLD LDX #$FF TXS + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 1 + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 2 + 0xA9, 0xFF, 0xA2, 0x00, // LDA #$FF LDX #0 + 0x9D, 0x00, 0x02, 0xE8, 0xD0, 0xFA, // fill $0200-$02FF with $FF + 0xA2, 0x00, 0xA0, 0x00, // LDX #0 LDY #0 + 0xA9, 0x64, 0x9D, 0x00, 0x02, // Y = 100 + 0xA9, 0x01, 0x9D, 0x01, 0x02, // tile 1 + 0xA9, 0x00, 0x9D, 0x02, 0x02, // attributes 0 + 0x98, 0x9D, 0x03, 0x02, // X = Y register + 0x18, 0x69, 0x10, 0xA8, // Y register += 16 + 0x8A, 0x18, 0x69, 0x04, 0xAA, // X register += 4 + 0xE0, 0x40, 0xD0, 0xE0, // 16 sprites + 0xA9, 0x3F, 0x8D, 0x06, 0x20, // $2006 = $3F + 0xA9, 0x11, 0x8D, 0x06, 0x20, // $2006 = $11 + 0xA9, 0x30, 0x8D, 0x07, 0x20, // $3F11 = $30 + 0x2C, 0x02, 0x20, 0x10, 0xFB, // loop: wait for vblank + 0xA9, 0x00, 0x8D, 0x03, 0x20, // OAMADDR = 0 + 0xA9, 0x02, 0x8D, 0x14, 0x40, // OAM DMA from $0200 + 0xA9, 0x14, 0x8D, 0x01, 0x20, // show sprites (and the left column) + 0x4C, 0x4C, 0xC0, // JMP loop ($C04C) + ]; + let mut rom = vec![b'N', b'E', b'S', 0x1A, 1, 1, 0, 0]; + rom.resize(16, 0); + let mut prg = vec![0xEAu8; 16 * 1024]; + prg[..code.len()].copy_from_slice(&code); + // NMI / RESET / IRQ vectors all point at $C000. + prg[0x3FFA..].copy_from_slice(&[0x00, 0xC0, 0x00, 0xC0, 0x00, 0xC0]); + rom.extend_from_slice(&prg); + let mut chr = vec![0u8; 8 * 1024]; + // Tile 1: low plane all set, high plane clear = colour 1, opaque. + chr[0x10..0x18].fill(0xFF); + rom.extend_from_slice(&chr); + rom +} + +fn boot(rom: &str) -> Nes { + if rom == CROWDED { + return Nes::from_rom(&crowded_rom()).expect("the built ROM parses"); + } + let path = rom_path(rom); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + Nes::from_rom(&bytes).unwrap_or_else(|e| panic!("parse {rom}: {e}")) +} + +/// Marker for [`crowded_rom`] in [`boot`]. +const CROWDED: &str = ""; + +/// Per frame: (framebuffer hash, RAM hash, audio hash, CPU cycle). +fn trace(disabled: bool, frames: u32) -> Vec<(u64, u64, u64, u64)> { + let mut nes = boot(CROWDED); + nes.set_sprite_limit_disabled(disabled); + (0..frames) + .map(|_| { + nes.run_frame(); + let mut audio = Vec::new(); + for s in nes.drain_audio() { + audio.extend_from_slice(&s.to_le_bytes()); + } + ( + fnv1a64(nes.framebuffer()), + fnv1a64(nes.bus().ram_bytes()), + fnv1a64(&audio), + nes.cycle(), + ) + }) + .collect() +} + +#[test] +fn off_by_default() { + assert!(!boot(CROWDED).sprite_limit_disabled()); +} + +#[test] +fn the_option_changes_the_picture_and_nothing_the_game_can_see() { + let frames = 300; + let stock = trace(false, frames); + let all = trace(true, frames); + let mut differing_frames = 0; + for (f, (a, b)) in stock.iter().zip(&all).enumerate() { + assert_eq!( + (a.1, a.2, a.3), + (b.1, b.2, b.3), + "frame {f}: RAM, audio or CPU cycle moved with the sprite-limit option on; \ + it must be render-only" + ); + if a.0 != b.0 { + differing_frames += 1; + } + } + assert!( + differing_frames > frames / 2, + "only {differing_frames} of {frames} frames drew differently; \ + the option is not reaching the picture" + ); +} + +#[test] +fn the_sprite_overflow_suite_passes_with_the_option_on() { + for rom in [ + "1.Basics", + "2.Details", + "3.Timing", + "4.Obscure", + "5.Emulator", + ] { + let path = rom_path(&format!("blargg/sprite_overflow_tests/{rom}.nes")); + let bytes = fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + let r = rustynes_test_harness::run_nes_blargg_with(&bytes, 600, &|nes| { + nes.set_sprite_limit_disabled(true); + }) + .expect("runs"); + assert_eq!(r.status, 0, "{rom} with the option on: {}", r.message); + } +} + +#[test] +fn the_option_rides_in_hardware_options() { + let mut nes = boot(CROWDED); + let stock = HardwareOptions::capture(&nes); + nes.set_sprite_limit_disabled(true); + let opts = HardwareOptions::capture(&nes); + assert!(opts.sprite_limit_disabled); + let back = HardwareOptions::read_from(&mut rustynes_core::BinReader::new(&opts.to_bytes())) + .expect("decodes"); + assert_eq!(back, opts); + assert_eq!(stock.differences(&opts), vec!["sprite limit"]); + let mut other = boot(CROWDED); + opts.apply_live(&mut other).expect("apply"); + assert!(other.sprite_limit_disabled()); +} + +#[test] +fn a_mid_frame_snapshot_keeps_the_next_lines_extra_sprites() { + let mut nes = boot(CROWDED); + nes.set_sprite_limit_disabled(true); + for _ in 0..120 { + nes.run_frame(); + } + // Into the visible frame, between a line's sprite fetch and the next + // line's pixels. + while nes.bus().ppu().scanline() < 100 { + nes.step_instruction(); + } + // Snapshot where line 101's extra sprites are already fetched (the + // fetch runs at dots 257-320 of line 100), or the test is blind. + while nes.bus().ppu().extra_sprite_count() == 0 { + nes.step_instruction(); + } + assert_eq!(nes.bus().ppu().extra_sprite_count(), 8); + let _ = nes.drain_audio(); + let snap = nes.snapshot(); + let run = |nes: &mut Nes| { + let mut h = Vec::new(); + for _ in 0..3 { + nes.run_frame(); + h.extend_from_slice(&fnv1a64(nes.framebuffer()).to_le_bytes()); + } + fnv1a64(&h) + }; + let first = run(&mut nes); + nes.restore(&snap).expect("own snapshot restores"); + assert!( + nes.sprite_limit_disabled(), + "configuration survives a restore" + ); + let second = run(&mut nes); + assert_eq!( + first, second, + "a restore mid-frame must redraw the same frames" + ); +} diff --git a/docs/adr/0044-movies-and-netplay-carry-the-emulation-options.md b/docs/adr/0044-movies-and-netplay-carry-the-emulation-options.md index 3e5909303..b4885c87e 100644 --- a/docs/adr/0044-movies-and-netplay-carry-the-emulation-options.md +++ b/docs/adr/0044-movies-and-netplay-carry-the-emulation-options.md @@ -111,3 +111,21 @@ What is recorded and what is not, knob by knob, is the survey table in - **v3.0.0 raises it to format 5 for the core timing epoch.** That adds no option; it records which emulator behaviour a movie was recorded under. See [ADR 0045](0045-a-core-timing-epoch-guards-movies-and-netplay.md). + +## Amendment (2026-10-07, v3.1.0): two more options, format 6, protocol 7 + +- **`HardwareOptions` gains `cpu_overclock`** (`T-CPU-OVERCLOCK`, encoded after + the extra-scanline count) and, in the same release, the sprite-limit option + (`T-SPRITE-LIMIT`), decided together as D22 with one format and one + protocol bump for both. +- **`.rnm` format 6, minimum 6.** A format-5 options record is shorter and + would decode as garbage, so it is refused as too old, by this ADR's own rule. +- **Netplay protocol 7, magic `"RNE7"`.** The `Sync` layout is protocol 6's; + the configuration hash's input changed, so the magic changes, and a + protocol-6 peer is refused as another emulator version (with its epoch) + rather than as a settings mismatch. +- **The CPU overclock is carried, not held at stock.** The extra-scanline + overclock (v2.9.7) is forced to stock while a movie records and under + netplay, because it predates this ADR's carriage. The CPU overclock is + recorded as set and replayed with it, and peers must match: the carriage + this ADR exists for, applied as intended. diff --git a/docs/frontend.md b/docs/frontend.md index 46119a8aa..5c4cf770b 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -2461,7 +2461,12 @@ says which emulator *behaviour* it was recorded on as well as which options and board. A format-4 movie is refused as too old, and a format-5 movie from another epoch fails with `MovieError::EpochMismatch`, naming both epochs. The epoch is raised whenever a change alters emulated output; the bump rule is in -ADR 0045. +ADR 0045. **v3.1.0 moved it to format 6** (`MOVIE_FORMAT_VERSION` 6, minimum +6): the options record gains the CPU-multiplier overclock and the sprite-limit +option, so a format-5 movie is refused as too old (every one of them was +recorded under epoch 1 or 2, which v3.1.0 refuses anyway). Unlike the +extra-scanline overclock, which a recording holds at stock, the CPU +overclock is recorded as set and replayed with it. - **Playback applies the options before frame 0** (`Movie::seek_to_start`), so the replay does not depend on the player's settings, and the desktop and mobile @@ -2510,8 +2515,13 @@ ADR 0045. The `Sync` handshake carries a `rustynes_netplay::SessionIdentity`: the emulation epoch, the ROM hash, and `rustynes_core::config_digest`, SHA-256 over -the region, the board and the options (`PROTOCOL_VERSION` 6, magic `"RNE6"`, -ADR 0045). +the region, the board and the options (`PROTOCOL_VERSION` 7, magic `"RNE7"`, +since v3.1.0; protocol 6 / `"RNE6"` from v3.0.0, ADR 0045). Protocol 7 keeps +protocol 6's 72-byte `Sync` and changes the magic because the options encoding +under the configuration hash gained two fields: a v3.0.x peer with the same +settings would otherwise hash differently and be refused as a settings +mismatch, the wrong reason. Its `"RNE6"` is now one of RustyNES's older magics, +refused as another emulator version naming its epoch. - **Another epoch is refused first**, as another emulator version: `DisconnectReason::EmulatorMismatch` / `NetplayError::EmulatorMismatch` / diff --git a/docs/mappers.md b/docs/mappers.md index 55eafc8d8..931a2f0bf 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -30,6 +30,9 @@ pub trait Mapper: Send { fn save_state(&self) -> Vec; fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError>; + + // v3.1.0 (`T-SPRITE-LIMIT`): `false` when a CHR read changes the board. + fn chr_reads_are_pure(&self) -> bool { true } } // `#[non_exhaustive]` since v3.0.0: outside `rustynes-mappers`, build one with @@ -56,6 +59,19 @@ pub struct Cartridge { pub enum Mirroring { Horizontal, Vertical, SingleScreenA, SingleScreenB, FourScreen, MapperControlled } ``` +**`chr_reads_are_pure` (v3.1.0).** The PPU's "disable sprite limit" option +makes extra, display-only pattern reads, and only on boards that report +`true`. Five report `false` because a CHR read changes them: MMC2 (9) and +MMC4 (10) switch a CHR latch on tiles `$FD` / `$FE`, the J.Y. ASIC (35, 90, +209, 211) clocks an IRQ counter on PPU reads and mapper 209 latches CHR, +Bandai 96 follows the last PPU address for its inner CHR bank, and Nanjing 163 +latches PPU A13. **A new board whose `ppu_read` writes `self` must override +it.** `every_board_that_claims_pure_chr_reads_has_them` (in `mapper.rs`) reads +all of CHR on every constructible mapper id that claims purity and fails if +`save_state` moved; it also pins the impure set, so the list here and the code +cannot drift apart. The set came from a scan of every `ppu_read` body for +writes to `self`. + `rustynes_mappers::parse(&[u8]) -> Result<(Cartridge, Box), RomError>` parses an iNES or NES 2.0 file (see `cartridge-format.md`), constructs the appropriate concrete mapper, and returns the metadata header together with diff --git a/docs/netplay-webrtc.md b/docs/netplay-webrtc.md index 0048b747a..8a7218eee 100644 --- a/docs/netplay-webrtc.md +++ b/docs/netplay-webrtc.md @@ -173,7 +173,8 @@ cartridge board and every `HardwareOptions` field), and a difference fails with options (ADR 0044). From v3.0.0 (`PROTOCOL_VERSION` 6, magic `"RNE6"`, ADR 0045) the identity also carries the emulation epoch. Another epoch, or an older RustyNES's `Sync` (`"RNES"`, `"RNE5"`, decoded at exactly its own length -and magic), fails with `MeshError::EmulatorMismatch` and its counterparts, so +and magic, and since v3.1.0's protocol 7 / `"RNE7"` also v3.0.x's `"RNE6"`, +which names its epoch), fails with `MeshError::EmulatorMismatch` and its counterparts, so a mixed-version session is refused with a reason instead of timing out. **Only a v3.0.0-or-later peer can give that reason.** A v2.9.9 or older peer ignores the `"RNE6"` magic as a foreign datagram and still times out on its diff --git a/docs/ppu-2c02.md b/docs/ppu-2c02.md index 1b3437a86..39d3e63c4 100644 --- a/docs/ppu-2c02.md +++ b/docs/ppu-2c02.md @@ -127,9 +127,41 @@ proved by `crates/rustynes-test-harness/tests/extra_scanlines.rs` (`extra_scanlines_zero_is_byte_identical_to_stock`, plus an image-invariance proof on the first frame and a CPU-cycle-growth check). -This is **distinct from the CPU-multiplier overclock**, which needs the -fractional-master-clock timebase rewrite and is a v2.0 item (ADR 0002); only the -dot-resolution scanline *insertion* is in scope here. +This is **distinct from the CPU-multiplier overclock** (`Nes::set_cpu_overclock`, +v3.1.0), which shortens the CPU cycle on the master clock rather than adding +lines, and lives in the bus (`docs/scheduler.md`, "CPU-multiplier overclock"). +Until v3.1.0 this paragraph called it a v2.0 timebase item; the one-clock +timebase that made it possible shipped in v2.0.0. + +### Disable sprite limit (v3.1.0, `T-SPRITE-LIMIT`, optional, default-OFF) + +`Nes::set_sprite_limit_disabled(true)` draws the sprites a scanline drops past +the eighth. It is **render-only**: evaluation, secondary OAM, the overflow +flag, sprite-0 hit and all eight real sprite fetches, with their A12 edges, +are exactly stock, so the game cannot tell. Pinned by +`crates/rustynes-test-harness/tests/sprite_limit.rs`: with the option on, every +CPU cycle, work-RAM byte and audio sample matches stock frame by frame, and +blargg's five sprite-overflow ROMs pass. + +- **Which sprites.** After the eighth real fetch (slot 7, dot 316) of a visible + line, `fetch_extra_sprites` walks primary OAM from entry 0, aligned, skips the + first eight in range for the next line (the ones the hardware draws when + evaluation starts at OAMADDR 0) and fetches up to 56 more + (`MAX_EXTRA_SPRITES`). Only when evaluation found eight; never on the + pre-render line. A line whose evaluation starts misaligned can draw a + slightly different set: it is a display enhancement, not hardware. +- **How they are read.** Through `PpuBus::ppu_read_sprite`, with no + `observe_a12_addr`, and only when `PpuBus::chr_reads_are_pure` (the mapper's + answer, `docs/mappers.md`): on MMC2, MMC4, the J.Y. ASIC, Bandai 96 and + Nanjing 163 a CHR read changes the board, so the option draws eight there. +- **How they draw.** In `emit_pixel`, only where none of the eight hardware + sprites is opaque (a higher OAM index is a lower priority), with their own + palette and priority bit, never as sprite 0. +- **State.** The pending extras for the next line are PPU snapshot v13 + (`spr_extra_*`), because a snapshot can fall between the fetch and the line. + The switch is configuration: carried across a power cycle, and in movies and + the netplay `config_digest` through `HardwareOptions` (the picture differs, + so frame hashes do). ### Power-up and reset state diff --git a/docs/scheduler.md b/docs/scheduler.md index ec9efbca3..f74876c2c 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -135,6 +135,33 @@ fractional or master-clock representation because its PPU:CPU ratio is 3.2. APU frame-counter tables also differ by region; do not scale NTSC cycle counts for PAL. +### CPU-multiplier overclock (v3.1.0, `T-CPU-OVERCLOCK`) + +`Nes::set_cpu_overclock(k)`, `k` in `1..=4` (`MAX_CPU_OVERCLOCK`), divides the +region's master-clock CPU divider: NTSC 12 becomes 6, 4 or 3; PAL 16 and Dendy +15 round down (PAL x3 is a divider of 5, x3.2). The PPU's divider is untouched, +so the CPU gets `k` times the cycles per frame. The smallest divider, 3, still +leaves both the read split `(0, 3)` and the write split `(2, 1)` valid. + +Everything that measures console time stays at the stock rate: the APU (and +with it the DMC), the mappers' `notify_cpu_cycle` IRQ counters, and the PPU's +open-bus decay and post-reset timers. The bus accumulates `overclock_debt`, the +master clocks the stock-rate domain is owed: each CPU cycle adds the effective +divider, and a **stock step** (those devices advance once) runs whenever the +debt reaches the stock divider. The APU is handed its own counter (`apu_cycle`) +instead of the CPU's, so its put/get phase advances once per stock step. DMA +follows that phase, so a DMA takes about `k` times as many CPU cycles and the +same real time. + +At `k = 1` the branch is never taken: every cycle is a stock step and the APU +gets the CPU counter, so the output is byte-identical (the epoch fingerprint +gate's whole panel runs at `k = 1`). The debt and `apu_cycle` are in the BUS +save-state section (version 3), because run-ahead restores mid-run; the +multiplier is configuration, carried by `HardwareOptions` in movies and the +netplay `config_digest`. `run_frame`'s cycle budget scales by `k`. Not hardware +behaviour: no console runs its CPU faster than its APU. +Tests: `crates/rustynes-test-harness/tests/cpu_overclock.rs`. + ### Frame complete The PPU reports `frame_complete()` when it transitions from scanline 240 to 241 (i.e., the start of vertical blank). The frontend consumes the framebuffer at this point. diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index ef6c4bc9c..730544f3d 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1446,8 +1446,8 @@ re-minted: `T-PS-dual-runahead` (ADR 0032), `T-MISTER-SAVESTATE`, | `T-LIBRETRO-TOOLCHAIN` | Drop the libretro build's Rust 1.96 pin if a branch pipeline on 1.99 passes all 15 jobs (D5). **Dropped in v3.0.1** (pipeline 119614, 15/15); **CLOSED v3.1.0**: the first `main` pipeline after the merge, 119931, ran 15/15 green | LR-02 | v3.0.1 | | `T-ACCURACYCOIN-RESYNC-2610` | Re-sync AccuracyCoin to upstream HEAD (two new tests and a "Misaligned OAM Behavior" fix since 2026-09-19) and triage red first (D25). **DONE v3.1.0**: `f5f41dc2`, 146/146; two defects fixed red first (write-refused DMC load DMA, misaligned sprite evaluation, the latter a false pass on the old ROM) | ecosystem 7 | v3.1.0 | | `T-EPOCH-FINGERPRINT` | A committed panel of output hashes that fails CI when it moves without an `EMULATION_EPOCH` rise. **DONE v3.1.0** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment) | CI-02 | v3.1.0 | -| `T-CPU-OVERCLOCK` | The CPU-multiplier overclock, in `HardwareOptions`, movies and netplay (D22) | FE-01 | v3.1.0 | -| `T-SPRITE-LIMIT` | "Disable sprite limit", render-only, carried like the overclock (D22) | FE-02 | v3.1.0 | +| `T-CPU-OVERCLOCK` | The CPU-multiplier overclock, in `HardwareOptions`, movies and netplay (D22) **DONE v3.1.0** | FE-01 | v3.1.0 | +| `T-SPRITE-LIMIT` | "Disable sprite limit", render-only, carried like the overclock (D22) **DONE v3.1.0** | FE-02 | v3.1.0 | | `T-PAL-EMPHASIS` | The PAL/Dendy emphasis red/green swap | ACC-01 | v3.1.0 | | `T-COMPOSITE-ARTIFACTS` | Differential phase distortion and inter-pixel artifacts, an opt-in video option (D20) | ACC-02 | v3.1.0 | | `T-MMC3-NEC-OVERRIDE` | The NEC rev B MMC3 as a per-game or config override (D20) | ACC-13 | v3.1.0 | diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index dfb2bc67d..a907c5352 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -90,3 +90,6 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | --- | --- | --- | | 1 | **Done.** Upstream `f5f41dc2`, 151 rows / 146 scored, **146 of 146**. Two defects fixed red first: the DMC load DMA a write refuses (4 cycles, not 3), and misaligned sprite evaluation (seed at dot 65, a four-byte copy from `m = 3`, the X range test). `Misaligned OAM behavior` had been a false pass on the old ROM (its fail path did not pop its return address, upstream `adacbc23`). `EMULATION_EPOCH` 3, BUS section 3. Extractor and `derive_indices.py` read `tblf1`/`tblf2` and fail closed; the mirror ROM is rebuilt. **Sibling consequence (read in `rtl/ppu2c02.sv`, 2026-10-07):** the RTL already seeds evaluation from OAMADDR at the end of the clear and already copies four bytes by a byte counter, but it never applies the X out-of-range `&$FC` (the inverse of the oracle's old defect); the DMC write-refusal path is unread yet. The pin move will move the AccuracyCoin goldens; the RTL needs the X rule (and the DMC rule, if absent), each with a gate that catches its mutant, written from the ROM's comments and nesdev, not from the oracle's Mesen2-derived evaluation region | battery red at errors 9, 3, 6, 7 in turn, each fix green; `misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x` catches 3 of 3 mutants; TriCNES per-cycle diff at the test-9 `STA $5000` | | 2 | **Done.** `tests/epoch_fingerprint.rs` + `golden/epoch_fingerprint.tsv` (epoch 3, `last_release_epoch` 2): seven panel ROMs, every frame's framebuffer, audio, end RAM, CPU cycles. Fails on moved output while the epoch equals the last release's and refuses that bless; after a rise, a re-bless suffices. ADR 0045 amended; the release cut must set `last_release_epoch` (`docs/agents/ci-and-release.md`) | with the cut simulated: control passes; both of this release's fixes reverted as mutants fail and their bless is refused (table unchanged); epoch 4 without bless fails, with bless passes; all files restored byte for byte | +| 3 | **Done.** `Nes::set_cpu_overclock(1..=4)`: the CPU divider is divided; the APU, DMC, mapper cycle hooks and PPU timers run on "stock steps" from a master-clock debt (`overclock_debt`, `apu_cycle`, BUS section 3); `run_frame`'s budget scales. `x1` byte-identical (epoch gate). Desktop: Settings combo, recorded by movies and matched by netplay (not held at stock like the scanline overclock). Not done: libretro and mobile options (neither exposes the scanline overclock either) | `cpu_overclock.rs` 6/6; mutants: every cycle a stock step (2 tests fail), `apu_cycle` parity flip and dropped debt on restore (snapshot test fails, after moving it to an audible ROM: ny2011 is silent for 300 frames and made it blind) | +| 4 | **Done.** Render-only extras: up to 56 sprites fetched after the eighth real fetch, drawn behind the hardware eight, PPU snapshot v13. `Mapper::chr_reads_are_pure` (5 impure boards found by scanning every `ppu_read` body; pinned for every constructible id against `save_state`). Desktop checkbox now live | `sprite_limit.rs` 5/5 (RAM, audio, cycles identical frame by frame; blargg sprite_overflow 1-5 pass with it on); stimulus built in the test (spritecans never exceeds 7 per line, NEStress's 62 are transparent); mutants: extras not drawn, extras dropped on restore, MMC2 / J.Y. ASIC claiming purity, all caught | +| 5 | **Done.** `.rnm` format 6 (minimum 6), netplay protocol 7 (`"RNE7"`, same `Sync` layout; `"RNE6"` refused naming its epoch); ADR 0044 amended | `a_format_5_movie_is_refused_as_too_old`, `a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch` | From 538221882b33a24c1d2f8b7be74fb5d98710cf52 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 21:20:29 -0400 Subject: [PATCH 05/21] feat(accuracy): PAL emphasis swap, NEC MMC3 override, differential phase v3.1.0 Sprint 3, plan items 6, 7 and 8 (T-PAL-EMPHASIS, T-COMPOSITE-ARTIFACTS, T-MMC3-NEC-OVERRIDE). Item 6 -- the PAL / Dendy emphasis bits. On the 2C07 and the Dendy PPU, PPUMASK bits 5 and 6 swap meaning: bit 5 emphasises green and bit 6 red, the reverse of the NTSC 2C02 (NESdev "PPU registers", PPUMASK's bit table: "Emphasize red (green on PAL/Dendy)"). The core applied the NTSC order everywhere, so every PAL or Dendy game that set emphasis was tinted the wrong way. `emit_pixel` now exchanges the two bits when the region is not NTSC, before the emphasis index reaches the palette; NTSC is untouched. Pinned by `pal_and_dendy_swap_the_red_and_green_emphasis_bits`. The epoch was already raised this release (3), and the fingerprint panel is NTSC, so it does not move. Item 8 -- the MMC3 IRQ revision as a setting, and the defect it found. `Mapper::set_mmc3_revision_override(Option)` forces Sharp or the alternate (NEC / MMC3A) behaviour on mapper 4 whatever the header says; `None` returns to the header's. It rides in `HardwareOptions` (`mmc3_revision`: 0 none, 1 Sharp, 2 Nec, named "MMC3 revision" in a mismatch), so a movie or a netplay peer on another value is refused. The bus keeps the override in `mmc3_revision_override` and re-applies it after a power cycle, which rebuilds the mapper from the ROM and would otherwise drop it (`the_mmc3_override_survives_a_power_cycle_and_clears`). The desktop exposes it as `[emulation] mmc3_irq_revision` (Auto / Sharp / Alternate) in Settings > Emulation, EN and ES. Running blargg's `mmc3_test_2/6-MMC3_alt` under the override is what the plan's gate asked for, and it FAILED: "IRQ should be set when reloading due to clear". MMC3.md states the rule: the alternate revision "generates only a single IRQ when $C000 is $00 ... In addition, writing to $C001 with $C000 still at $00 will result in another single IRQ being generated." The mapper's reload path asserted on a $C001 reload to 0 only for Sharp. Path 1 of `clock_irq` (the explicit reload) now asserts for both revisions when the reloaded value is 0; path 2 (the natural reload of a counter already at 0) still asserts on Sharp only, which is the "single IRQ" half. With that, `6-MMC3_alt` passes under the override and `5-MMC3` then fails, proving the override acts; all 20 MMC3 tests pass at the default. Two older tests pinned the opposite of the documented rule and are corrected here, with the wiki's text in their docs rather than silently: `nec_does_not_assert_on_reload_to_zero` became `nec_asserts_once_on_a_c001_reload_to_zero_and_not_after` (one IRQ on the $C001 reload, none on the was-zero reload, one more on a second $C001 write), and `mmc3_submapper_4_is_nec_and_0_is_sharp` now expects exactly one IRQ from its arm sequence, not zero. The submapper table's "Loading the latch with 0 disables IRQ" is shorthand; MMC3.md is exact. The schema audit classifies `mmc3_revision_override` as host configuration: a restore loads into the live mapper, whose revision the override already set. Item 7 -- differential phase, display only. `signal_decode.wgsl` rotates the decoded I/Q by `-knobs.w * row`, where `knobs.w` is radians per palette luma row, which is how the PPU's brighter rows shift hue (about 2.5 degrees per row on a 2C02E, 5 on a 2C02G). A sixth pragma parameter `diff_phase` (default 0) feeds `u[15]`. At 0 the rotation is the identity, so the default picture is unchanged; `shader_pass` pins six parameters with `diff_phase` defaulting to 0. It changes no emulated output, so no epoch rise. The plan's "inter-pixel artifacts" half is the existing signal decode; only the differential phase was missing. Verification, on this tree plus the Sprint 4 work that follows it: cargo test --release --workspace --features test-roms: 3,262 passed, 0 failed, 11 ignored. fmt clean; clippy -D warnings for the workspace and for scripting, scripting+hd-pack, retroachievements and full; both wasm32 clippy builds; rustdoc -D warnings; the thumbv7em no_std build. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 14 +++ crates/rustynes-core/src/bus.rs | 28 ++++++ crates/rustynes-core/src/hardware_options.rs | 29 +++++- crates/rustynes-core/src/nes.rs | 25 +++++ crates/rustynes-frontend/src/app.rs | 3 + crates/rustynes-frontend/src/config.rs | 35 +++++++ crates/rustynes-frontend/src/crt.rs | 4 + .../src/debugger/settings_panel.rs | 28 ++++++ crates/rustynes-frontend/src/i18n.rs | 5 + crates/rustynes-frontend/src/shader_pass.rs | 14 ++- .../src/signal_decode.wgsl | 25 ++++- crates/rustynes-mappers/src/m004_mmc3.rs | 91 +++++++++++++------ crates/rustynes-mappers/src/mapper.rs | 9 ++ crates/rustynes-ppu/src/emphasis.rs | 7 +- crates/rustynes-ppu/src/ppu.rs | 63 ++++++++++++- crates/rustynes-test-harness/tests/mmc3.rs | 64 ++++++++++++- .../tests/roster_boards.rs | 13 ++- .../tests/snapshot_schema_audit.rs | 7 ++ docs/frontend.md | 19 +++- docs/mappers.md | 2 +- docs/ppu-2c02.md | 9 ++ to-dos/ROADMAP.md | 6 +- to-dos/plans/v3.1.0-plan.md | 3 + 23 files changed, 459 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e0b28b3..0bd504a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,12 @@ cycle-accurate core later replaced. in-range X (it always realigned). The old ROM recorded these failures as a pass: its fail path returned into the test without popping the return address, fixed upstream in `adacbc23`. +- **PAL and Dendy games that use colour emphasis show the right tint.** On the + PAL 2C07 and the Dendy, PPUMASK bits 5 and 6 swap meaning (green and red); + every PAL or Dendy game that set emphasis was tinted the wrong way. +- **The alternate MMC3 IRQ revision fires on a `$C001` reload to 0**, as the + MMC3A and non-Sharp MMC3B do (NES 2.0 submapper 4, and mapper 12). Sharp, + the default, is unchanged. - **The AccuracyCoin tooling reads upstream's new row macros.** `extract_catalog.py` returned 85 of 151 rows and exited 0 when upstream compressed the unofficial-opcode rows into `tblf1` / `tblf2`; it now @@ -78,6 +84,14 @@ cycle-accurate core later replaced. sprite fetches and every CPU cycle stay exact. It is skipped on the five boards whose pattern reads change the cartridge (MMC2, MMC4, the J.Y. ASIC, Bandai 96, Nanjing 163). +- **Differential phase distortion in the raw NTSC signal decode** + (`T-COMPOSITE-ARTIFACTS`): a new "Differential phase" slider rotates brighter + colours' hue as the NES PPU does (about 2.5° per palette row on a 2C02E, 5° on + a 2C02G). Off by default; display only. +- **MMC3 IRQ revision setting** (`T-MMC3-NEC-OVERRIDE`, Settings > Emulation): + run any mapper-4 game on the Sharp or the alternate (MMC3A / NEC) chip, for + dumps whose header cannot say which. blargg's `mmc3_test_2/6-MMC3_alt` passes + under the alternate setting. Carried in movies and netplay. - **A test now enforces the emulation-epoch rule** (`T-EPOCH-FINGERPRINT`). It fingerprints seven test ROMs (frames, audio, RAM, CPU cycles) and fails when that output moves while `EMULATION_EPOCH` still equals the last diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 9d7676397..e5a6ba097 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -583,6 +583,10 @@ pub struct SystemBus { /// extra-scanline overclock: not part of the save-state, re-applied by /// the host (and by a movie's or netplay session's options record). cpu_overclock: u8, + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`): a forced MMC3 IRQ revision, or `None` + /// for the header's. Configuration, re-applied to every mapper the bus + /// builds (a power cycle rebuilds it); only mapper 4 acts on it. + mmc3_revision_override: Option, /// The master clocks one CPU cycle takes under the overclock: /// `cpu_div_cached / cpu_overclock`, rounded down, so `x3` on PAL (16) /// is 5 (x3.2) and `x2` on Dendy (15) is 7 (x2.14). Equals @@ -1002,6 +1006,7 @@ impl SystemBus { cpu_div_cached, ppu_div_cached, cpu_overclock: 1, + mmc3_revision_override: None, cpu_div_effective: cpu_div_cached, overclock_debt: 0, apu_cycle: 0, @@ -1280,6 +1285,12 @@ impl SystemBus { // fresh mapper instance (same type, same flags, but keep // the invariant mechanical). self.mapper_caps = self.mapper.caps(); + // v3.1.0: a fresh board starts on its header's MMC3 revision; + // the override is configuration and carries over. + if self.mmc3_revision_override.is_some() { + self.mapper + .set_mmc3_revision_override(self.mmc3_revision_override); + } } self.rom_bytes = Some(bytes); } @@ -1421,6 +1432,23 @@ impl SystemBus { self.cpu_overclock } + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`) — force the MMC3 IRQ revision, or + /// `None` for the header's. Returns whether the board applied it (only an + /// MMC3, mapper 4, does); the setting is kept either way. + pub fn set_mmc3_revision_override( + &mut self, + revision: Option, + ) -> bool { + self.mmc3_revision_override = revision; + self.mapper.set_mmc3_revision_override(revision) + } + + /// v3.1.0 — the forced MMC3 IRQ revision (`None` = the header's). + #[must_use] + pub const fn mmc3_revision_override(&self) -> Option { + self.mmc3_revision_override + } + /// v3.1.0 (`T-SPRITE-LIMIT`) — draw the sprites beyond the eighth on a /// scanline (forwarded to the PPU). pub const fn set_sprite_limit_disabled(&mut self, disabled: bool) { diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index c18a6428b..f87db2155 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -141,6 +141,9 @@ pub struct HardwareOptions { /// Draw the sprites beyond the eighth on a scanline /// ([`Nes::set_sprite_limit_disabled`]); render-only. v3.1.0. pub sprite_limit_disabled: bool, + /// A forced MMC3 IRQ revision ([`Nes::set_mmc3_revision_override`]); + /// `None` = the header's. v3.1.0. + pub mmc3_revision: Option, /// Whether the Four Score adapter is plugged in ([`Nes::set_four_score`]). /// It changes `$4016` / `$4017` reads 9-24 even with players 3/4 idle. pub four_score: bool, @@ -173,6 +176,7 @@ impl Default for HardwareOptions { extra_scanlines: 0, cpu_overclock: 1, sprite_limit_disabled: false, + mmc3_revision: None, four_score: false, // The core's own default since v2.2.x (`zapper_temporal_light` // is on in a freshly-built `Nes`); the stock machine, not `false`. @@ -199,6 +203,7 @@ impl HardwareOptions { extra_scanlines: nes.extra_scanlines(), cpu_overclock: nes.cpu_overclock(), sprite_limit_disabled: nes.sprite_limit_disabled(), + mmc3_revision: nes.mmc3_revision_override(), four_score: nes.four_score(), zapper_temporal_light: nes.zapper_temporal_light(), vs_dip: nes.vs_dip(), @@ -260,6 +265,9 @@ impl HardwareOptions { if nes.sprite_limit_disabled() != self.sprite_limit_disabled { nes.set_sprite_limit_disabled(self.sprite_limit_disabled); } + if nes.mmc3_revision_override() != self.mmc3_revision { + nes.set_mmc3_revision_override(self.mmc3_revision); + } if nes.four_score() != self.four_score { nes.set_four_score(self.four_score); } @@ -327,9 +335,10 @@ impl HardwareOptions { /// /// Layout (all little-endian): console model, PPU revision, 2A03 /// revision, OAM decay, power-on RAM kind + `u64` payload, power-up - /// palette, extra scanlines (`u16`), CPU overclock and the sprite-limit - /// flag (both since `.rnm` format 6 and netplay protocol 7, v3.1.0), Four - /// Score, Zapper light model, Vs. + /// palette, extra scanlines (`u16`), CPU overclock, the sprite-limit flag + /// and the MMC3 revision override (`0` = header, `1` = Sharp, `2` = the + /// alternate; all three since `.rnm` format 6 and netplay protocol 7, + /// v3.1.0), Four Score, Zapper light model, Vs. /// DIP, Vs. PPU type (`0xFF` = the header's), mirroring override (`0` = /// none, else variant + 1), then a code count and each Game Genie code as /// a length byte plus ASCII. Every enum is an explicit byte, never a @@ -363,6 +372,11 @@ impl HardwareOptions { w.u16(self.extra_scanlines); w.u8(self.cpu_overclock); w.u8(u8::from(self.sprite_limit_disabled)); + w.u8(match self.mmc3_revision { + None => 0, + Some(rustynes_mappers::Mmc3Revision::Sharp) => 1, + Some(rustynes_mappers::Mmc3Revision::Nec) => 2, + }); w.u8(u8::from(self.four_score)); w.u8(u8::from(self.zapper_temporal_light)); w.u8(self.vs_dip); @@ -433,6 +447,12 @@ impl HardwareOptions { return Err("CPU overclock is outside 1..=MAX_CPU_OVERCLOCK"); } let sprite_limit_disabled = flag(r, "sprite-limit flag is not 0 or 1")?; + let mmc3_revision = match byte(r)? { + 0 => None, + 1 => Some(rustynes_mappers::Mmc3Revision::Sharp), + 2 => Some(rustynes_mappers::Mmc3Revision::Nec), + _ => return Err("unknown MMC3 revision byte"), + }; let four_score = flag(r, "Four Score flag is not 0 or 1")?; let zapper_temporal_light = flag(r, "Zapper light flag is not 0 or 1")?; let vs_dip = byte(r)?; @@ -469,6 +489,7 @@ impl HardwareOptions { extra_scanlines, cpu_overclock, sprite_limit_disabled, + mmc3_revision, four_score, zapper_temporal_light, vs_dip, @@ -518,6 +539,7 @@ impl HardwareOptions { self.sprite_limit_disabled != other.sprite_limit_disabled, "sprite limit", ); + check(self.mmc3_revision != other.mmc3_revision, "MMC3 revision"); check(self.four_score != other.four_score, "Four Score"); check( self.zapper_temporal_light != other.zapper_temporal_light, @@ -814,6 +836,7 @@ mod tests { extra_scanlines: 40, cpu_overclock: 3, sprite_limit_disabled: true, + mmc3_revision: Some(rustynes_mappers::Mmc3Revision::Nec), four_score: true, zapper_temporal_light: false, vs_dip: 0xA5, diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 65df7dc20..56fb9d9b4 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -3101,6 +3101,31 @@ impl Nes { self.bus.sprite_limit_disabled() } + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13) — run an MMC3 (mapper 4) under + /// a chosen IRQ revision, or `None` for the one its header selects. + /// + /// The MMC3's two IRQ behaviours are mutually exclusive: the Sharp MMC3B + /// / MMC3C (the default) asserts IRQ when the counter is reloaded to 0, the + /// MMC3A and non-Sharp MMC3B (`Mmc3Revision::Nec`) only on a 1 -> 0 + /// decrement. A NES 2.0 header can say which (submapper 4); an iNES 1.0 + /// dump cannot, so this override is how a player runs a game, or blargg's + /// `mmc3_test_2/6-MMC3_alt`, on the other chip. The default (`None`) is + /// unchanged. Configuration, re-applied when a power cycle rebuilds the + /// board, and carried in [`crate::HardwareOptions`]. Returns whether the + /// board is one that applies it. + pub fn set_mmc3_revision_override( + &mut self, + revision: Option, + ) -> bool { + self.bus.set_mmc3_revision_override(revision) + } + + /// v3.1.0 — the forced MMC3 IRQ revision (`None` = the header's). + #[must_use] + pub const fn mmc3_revision_override(&self) -> Option { + self.bus.mmc3_revision_override() + } + /// v1.7.0 F3 — the configured extra-scanline overclock count (`0` = stock). #[must_use] pub const fn extra_scanlines(&self) -> u16 { diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index ec15a30c5..f53cd00bd 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -538,6 +538,9 @@ fn push_ppu_hardware_config(config: &crate::config::Config, nes: &mut Nes) { // pushing it here is purely about honouring the user's escape hatch. // Default on. nes.set_fast_dotloop(config.emulation.fast_dotloop); + // v3.1.0 — the MMC3 IRQ revision override (`auto` leaves the header's). + // A board, not a timing knob: only mapper 4 acts on it. + nes.set_mmc3_revision_override(config.emulation.mmc3_irq_revision.to_core()); } /// v2.9.8 — the `[emulation] famicom_console` choice as a diff --git a/crates/rustynes-frontend/src/config.rs b/crates/rustynes-frontend/src/config.rs index 5ae3d3abc..7d590d9ef 100644 --- a/crates/rustynes-frontend/src/config.rs +++ b/crates/rustynes-frontend/src/config.rs @@ -1887,6 +1887,15 @@ pub struct EmulationConfig { #[serde(default)] pub famicom_console: bool, + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`) — which MMC3 IRQ revision mapper-4 + /// games run on: `auto` (the default; the header decides, Sharp unless a + /// NES 2.0 header says submapper 4), `sharp`, or `alternate` (the MMC3A + /// and non-Sharp MMC3B). An iNES 1.0 dump cannot name its chip, so this is + /// how to run one on the other. Pushed into the core via + /// `Nes::set_mmc3_revision_override` on ROM load and power cycle. + #[serde(default)] + pub mmc3_irq_revision: Mmc3IrqRevision, + /// v2.1.8 A1 / v2.2.3 — use the specialized visible-scanline fast dot path /// (`Nes::set_fast_dotloop`). **On by default**, and unlike every other /// field here it is **not an accuracy knob**: the fast path runs the same @@ -1907,6 +1916,31 @@ pub struct EmulationConfig { pub fast_dotloop: bool, } +/// v3.1.0 — the `[emulation] mmc3_irq_revision` choice. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum Mmc3IrqRevision { + /// The cartridge header decides (Sharp unless NES 2.0 submapper 4). + #[default] + Auto, + /// The Sharp MMC3B / MMC3C: a latch of 0 fires every scanline. + Sharp, + /// The MMC3A and non-Sharp MMC3B: a latch of 0 stops IRQs. + Alternate, +} + +impl Mmc3IrqRevision { + /// The core override this choice selects. + #[must_use] + pub const fn to_core(self) -> Option { + match self { + Self::Auto => None, + Self::Sharp => Some(rustynes_core::rustynes_mappers::Mmc3Revision::Sharp), + Self::Alternate => Some(rustynes_core::rustynes_mappers::Mmc3Revision::Nec), + } + } +} + /// Serde + [`Default`] value for [`EmulationConfig::fast_dotloop`] — `true`. /// /// A named function because `bool`'s `Default` is `false`, which would both @@ -1928,6 +1962,7 @@ impl Default for EmulationConfig { randomize_power_on_ram: false, power_on_ram_seed: 0, famicom_console: false, + mmc3_irq_revision: Mmc3IrqRevision::Auto, fast_dotloop: default_fast_dotloop(), } } diff --git a/crates/rustynes-frontend/src/crt.rs b/crates/rustynes-frontend/src/crt.rs index dcd5e9ce1..084636664 100644 --- a/crates/rustynes-frontend/src/crt.rs +++ b/crates/rustynes-frontend/src/crt.rs @@ -109,6 +109,10 @@ pub const SIGNAL_DECODE_STACK_PARAMS: &str = concat!( "// #pragma parameter brightness \"Brightness\" 1.0 0.5 1.5 0.02\n", "// #pragma parameter contrast \"Contrast\" 1.0 0.5 1.5 0.02\n", "// #pragma parameter hue \"Hue (radians)\" 0.0 -1.0 1.0 0.02\n", + // v3.1.0 (`T-COMPOSITE-ARTIFACTS`): the documented differential phase + // distortion, degrees of hue rotation per palette row. 0 = off; NESdev + // estimates 2.5 for a 2C02E and 5 for a 2C02G. + "// #pragma parameter diff_phase \"Differential phase (deg/row; 2.5 = 2C02E, 5 = 2C02G)\" 0.0 0.0 10.0 0.5\n", ); /// CRT / scanline post-process filter. diff --git a/crates/rustynes-frontend/src/debugger/settings_panel.rs b/crates/rustynes-frontend/src/debugger/settings_panel.rs index 720309d46..c540c9ba3 100644 --- a/crates/rustynes-frontend/src/debugger/settings_panel.rs +++ b/crates/rustynes-frontend/src/debugger/settings_panel.rs @@ -1856,6 +1856,34 @@ pub fn advanced_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi save_config(config); } + // v3.1.0 — the MMC3 IRQ revision for mapper-4 games (`auto` = the header). + // Like the console model it takes effect at the next ROM load or power + // cycle, through the same push. + { + use crate::config::Mmc3IrqRevision as R; + let label = |r: R| match r { + R::Auto => crate::t!(SetMmc3RevAuto).to_string(), + R::Sharp => crate::t!(SetMmc3RevSharp).to_string(), + R::Alternate => crate::t!(SetMmc3RevAlternate).to_string(), + }; + let before = config.emulation.mmc3_irq_revision; + ui.horizontal(|ui| { + ui.label(crate::t!(SetMmc3Revision)) + .on_hover_text(crate::t!(SetMmc3RevisionHover)); + egui::ComboBox::from_id_salt("emu-mmc3-revision") + .selected_text(label(before)) + .show_ui(ui, |ui| { + for r in [R::Auto, R::Sharp, R::Alternate] { + ui.selectable_value(&mut config.emulation.mmc3_irq_revision, r, label(r)); + } + }); + }); + if config.emulation.mmc3_irq_revision != before { + state.apply.console_model = true; + save_config(config); + } + } + // v2.2.3 — the specialized PPU fast dot path. NOT an accuracy toggle: both // paths emit the identical framebuffer/audio/cycle count (pinned every frame // by `fast_dotloop_diff`), so this is a performance selector with an escape diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index 90445cbab..2780e442b 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -720,6 +720,11 @@ catalog! { SetAccuracy => "Accuracy", Some("Precisión"); SetOamDecay => "OAM decay (accuracy)", Some("Degradación de OAM (precisión)"); SetFamicomConsole => "Famicom console (PPU leaves reset early)", Some("Consola Famicom (la PPU sale antes del reinicio)"); + SetMmc3Revision => "MMC3 IRQ revision", Some("Revisión de IRQ del MMC3"); + SetMmc3RevAuto => "Auto (from the ROM)", Some("Automática (según la ROM)"); + SetMmc3RevSharp => "Sharp (MMC3B/C)", Some("Sharp (MMC3B/C)"); + SetMmc3RevAlternate => "Alternate (MMC3A, NEC MMC3B)", Some("Alternativa (MMC3A, MMC3B de NEC)"); + SetMmc3RevisionHover => "Which MMC3 chip mapper-4 games run on. The two differ only when a game sets the IRQ latch to 0. Auto uses the ROM header, which says Sharp unless it is a NES 2.0 header naming the alternate chip. Takes effect from the next power cycle or ROM load.", Some("En qué chip MMC3 se ejecutan los juegos del mapper 4. Los dos solo difieren cuando un juego pone el latch de IRQ a 0. Automática usa la cabecera de la ROM, que indica Sharp salvo que sea una cabecera NES 2.0 que nombre el chip alternativo. Surte efecto desde el siguiente apagado y encendido o carga de ROM."); SetFastDotPath => "Fast PPU dot path (performance, not accuracy)", Some("Ruta rápida de puntos de la PPU (rendimiento, no precisión)"); SetEnhancements => "Enhancements (non-accuracy)", Some("Mejoras (ajenas a la precisión)"); SetDisableSpriteLimit => "Disable 8-sprite-per-scanline limit (reduces flicker)", Some("Desactivar el límite de 8 sprites por línea (reduce el parpadeo)"); diff --git a/crates/rustynes-frontend/src/shader_pass.rs b/crates/rustynes-frontend/src/shader_pass.rs index b8acf3f9d..0cb1eafe9 100644 --- a/crates/rustynes-frontend/src/shader_pass.rs +++ b/crates/rustynes-frontend/src/shader_pass.rs @@ -727,6 +727,8 @@ impl ShaderStack { u[12] = pv(2, 1.0); // knobs.x = brightness u[13] = pv(3, 1.0); // knobs.y = contrast u[14] = pv(4, 0.0); // knobs.z = hue + // v3.1.0: knobs.w = differential phase, degrees -> radians. + u[15] = pv(5, 0.0).to_radians(); } BuiltinPass::Ntsc => {} } @@ -1021,12 +1023,22 @@ mod tests { ("crt-royale", 7usize), ("crt-guest", 7), ("megatron", 7), - ("signal-decode", 5), + ("signal-decode", 6), ] { let p = BuiltinPass::from_id(id).unwrap_or_else(|| panic!("{id} must resolve")); assert_eq!(p.id(), id); assert_eq!(p.params().len(), min_params, "{id} param count"); } + // v3.1.0 (`T-COMPOSITE-ARTIFACTS`): the sixth signal-decode knob is the + // differential phase, and it defaults to OFF, so a saved stack decodes + // exactly as it did. + let params = BuiltinPass::SignalDecode.params(); + let dp = params.last().expect("six params"); + assert_eq!(dp.name, "diff_phase"); + assert!( + dp.default.abs() < f32::EPSILON, + "differential phase is off by default" + ); } #[test] diff --git a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl index e89d71921..eae25cd7b 100644 --- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl +++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl @@ -27,7 +27,8 @@ // rect, crop as in CRT_WGSL. // params: (x = video phase / line offset, y = saturation, z = sharpness 0..1, // w = source rows, default 240) -// knobs : (x = brightness, y = contrast, z = hue radians, w unused) +// knobs : (x = brightness, y = contrast, z = hue radians, +// w = differential phase, radians per palette row; v3.1.0, 0 = off) // // Presentation only — reads the index framebuffer, never the core state. @@ -205,6 +206,28 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { var i = i_acc * inv * sat; var q = q_acc * inv * sat; + // v3.1.0 (`T-COMPOSITE-ARTIFACTS`, ACC-02): differential phase distortion, + // opt-in. NESdev "NTSC video": the PPU's output impedance depends on the + // signal level, which delays the chroma phase more at higher levels, so the + // hue rotates "about 2.5 degrees (2C02E) or 5 degrees (2C02G) ... for each + // row of the palette". Modelled as that rotation for the centre pixel's + // palette row (0-3). A DELAY subtracts from the recovered phase, hence the + // minus sign. Greys carry no chroma, so they are unaffected; `knobs.w` = 0 + // (every host's default) is the previous output exactly. + if (u.knobs.w != 0.0) { + let ccx = clamp(i32(floor(fcol)), 0, i32(dim.x) - 1); + let ccy = clamp(row, 0, i32(dim.y) - 1); + let cpk = i32(textureLoad(idx_tex, vec2(ccx, ccy), 0).r); + let prow = f32(((cpk & 0x3F) >> 4) & 0x3); + let dpa = -u.knobs.w * prow; + let ca = cos(dpa); + let sa = sin(dpa); + let ir = i * ca - q * sa; + let qr = i * sa + q * ca; + i = ir; + q = qr; + } + // Hue rotate. let ct = cos(hue); let st = sin(hue); diff --git a/crates/rustynes-mappers/src/m004_mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs index 0b68cc26a..3e853936a 100644 --- a/crates/rustynes-mappers/src/m004_mmc3.rs +++ b/crates/rustynes-mappers/src/m004_mmc3.rs @@ -42,17 +42,21 @@ //! sprites @ `$0000`) places the edge at the END of the previous //! scanline's sprite fetches (Wario's Woods relies on this). //! -//! On each filtered rising edge: -//! - if `counter == 0` OR `irq_reload_pending`: counter = `irq_reload_value`; -//! pending cleared; **Sharp** revision additionally asserts IRQ if the -//! reload value was 0 (from a non-zero counter); **NEC** does not. -//! - else: counter -= 1; if post-decrement counter == 0 AND IRQ enabled, -//! assert IRQ line. +//! On each filtered rising edge (`clock_irq`): +//! - a `$C001` reload (`irq_reload_pending`): counter = `irq_reload_value`, +//! flag cleared; BOTH revisions assert if the new value is 0 and IRQs are +//! enabled (v3.1.0; the alternate one used not to, see `clock_irq`); +//! - else if `counter == 0`: counter = `irq_reload_value`; only **Sharp** +//! asserts if the new value is 0, so a latch of 0 fires every scanline on +//! Sharp and stops on the alternate chip; +//! - else: counter -= 1; if it reached 0 and IRQs are enabled, assert. //! //! Default revision is **Sharp** per project policy (Star Trek: 25th -//! Anniversary requires it); NES 2.0 submapper 1 selects MMC3B (NEC), -//! submapper 2 selects MMC3C (Sharp behavior + minor differences not -//! distinguished here). +//! Anniversary requires it). NES 2.0 submapper 4 selects the alternate +//! behaviour of the MMC3A and non-Sharp MMC3B ([`Mmc3Revision::Nec`]), 1 the +//! MMC6 (v2.9.6 corrected both; this paragraph said "submapper 1 selects +//! MMC3B (NEC)" until v3.1.0). For an iNES 1.0 dump, which cannot say, +//! `Mapper::set_mmc3_revision_override` selects it (v3.1.0). #![allow( clippy::cast_possible_truncation, @@ -97,7 +101,7 @@ const MMC6_RAM: usize = 0x0400; /// and the NES 2.0 submapper for it is 4, not 1 (`NES_2_0_submappers.md`). /// The behaviour of each variant was always right; only the labels were /// wrong, together with the submapper mapping that followed them. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] pub enum Mmc3Revision { /// Sharp MMC3B and MMC3C: reloading the IRQ counter to 0 asserts IRQ if /// IRQs are enabled, so a latch of 0 fires every scanline. Default. @@ -203,6 +207,11 @@ pub struct Mmc3 { cpu_cycle: u64, revision: Mmc3Revision, + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`): the revision the cartridge header + /// selected, which `revision` returns to when an override is cleared. + /// Board identity, fixed at construction; not save-state (a state carries + /// the live `revision`). + header_revision: Mmc3Revision, variant: Mmc3Variant, /// MMC6: `$8000` bit 5, the PRG-RAM enable. mmc6_ram_enabled: bool, @@ -321,6 +330,7 @@ impl Mmc3 { a12_low_cycle: 0, cpu_cycle: 0, revision, + header_revision: revision, variant: Mmc3Variant::Standard, mmc6_ram_enabled: false, mmc6_protect: 0, @@ -533,7 +543,18 @@ impl Mmc3 { /// 3. Otherwise: decrement. /// /// After any of the three, Sharp asserts when the counter is zero and - /// IRQs are enabled; NEC only after path 3. + /// IRQs are enabled. The alternate revision (`Nec`) asserts after path 3, + /// and after path 1 when the reloaded value is 0, but never after path 2. + /// + /// **Changed in v3.1.0 (`T-MMC3-NEC-OVERRIDE`).** The alternate revision + /// asserted only after path 3. `MMC3.md`: "The 'alternate revision' checks + /// the IRQ counter transition 1→0, whether from decrementing or + /// reloading", and "writing to $C001 with $C000 still at $00 will result in + /// another single IRQ being generated". blargg's `mmc3_test_2/6-MMC3_alt` + /// says the same ("IRQ should be set when reloading due to clear, even if + /// counter was already 0") and could not run until v3.1.0 added a way to + /// select this revision for an iNES 1.0 ROM; it failed there on exactly + /// this, and passes now. The Sharp path is unchanged. /// /// **Changed in v2.9.9 (T-ORACLE-001).** Path 1 used to assert only when /// the `$C001` write had cleared a non-zero counter (a latch named @@ -546,13 +567,12 @@ impl Mmc3 { fn clock_irq(&mut self) -> bool { let mut would_assert = false; if self.irq_reload_pending { - // Path 1: explicit $C001 reload. + // Path 1: explicit $C001 reload. Both revisions assert when the + // reloaded value is 0 (for the alternate one, the single IRQ a + // $C001 write with $C000 = $00 produces). self.irq_counter = self.irq_reload_value; self.irq_reload_pending = false; - if self.irq_enabled - && self.irq_counter == 0 - && matches!(self.revision, Mmc3Revision::Sharp) - { + if self.irq_enabled && self.irq_counter == 0 { would_assert = true; } } else if self.irq_counter == 0 { @@ -577,6 +597,14 @@ impl Mmc3 { } impl Mapper for Mmc3 { + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`): `Some` forces the IRQ revision, `None` + /// returns to the header's. Applies to mapper 4 itself; the MMC3-derived + /// boards that embed this core keep their own revision. + fn set_mmc3_revision_override(&mut self, revision: Option) -> bool { + self.revision = revision.unwrap_or(self.header_revision); + true + } + fn sram(&self) -> &[u8] { &self.prg_ram } @@ -1349,11 +1377,15 @@ mod tests { ); } - /// NEC (Rev B) does NOT assert on reload-to-0 even on the natural - /// was_zero path. Mutually exclusive with the Sharp behavior tested - /// above. + /// NEC (the "alternate" revision) asserts once on a `$C001` reload to 0, + /// and never on the natural `was_zero` reload. `MMC3.md`: it "generates + /// only a single IRQ when `$C000` is `$00`", and "writing to `$C001` with + /// `$C000` still at `$00` will result in another single IRQ"; blargg's + /// `6-MMC3_alt` fails with "IRQ should be set when reloading due to + /// clear" otherwise. v3.1.0 corrected this: until then the test pinned + /// NEC as silent on both paths. #[test] - fn nec_does_not_assert_on_reload_to_zero() { + fn nec_asserts_once_on_a_c001_reload_to_zero_and_not_after() { let mut m = Mmc3::new( synth_prg(8), synth_chr(8), @@ -1362,8 +1394,6 @@ mod tests { Mmc3Revision::Nec, ) .unwrap(); - // Same "non-zero clear" setup as the Sharp test, but on NEC the - // reload-to-0 should NOT assert. m.cpu_write(0xC000, 1); m.cpu_write(0xC001, 0); m.cpu_write(0xE001, 0); @@ -1372,13 +1402,18 @@ mod tests { m.cpu_write(0xC001, 0); a12_rise(&mut m); assert_eq!(m.irq_counter, 0); - assert!( - !m.irq_pending(), - "NEC suppresses Sharp's reload-to-0 assertion" - ); - // Even the natural was_zero path doesn't assert on NEC. + assert!(m.irq_pending(), "the $C001 reload to 0 asserts on NEC too"); + // Acknowledge, then the natural was_zero reload stays silent. + m.cpu_write(0xE000, 0); + m.cpu_write(0xE001, 0); + assert!(!m.irq_pending()); + a12_rise(&mut m); + a12_rise(&mut m); + assert!(!m.irq_pending(), "NEC: the was_zero reload to 0 is silent"); + // A second $C001 write with $C000 still 0: another single IRQ. + m.cpu_write(0xC001, 0); a12_rise(&mut m); - assert!(!m.irq_pending(), "NEC: was_zero reload-to-0 also silent"); + assert!(m.irq_pending(), "each $C001 write gives one more IRQ"); } /// T-41-005 — reversed pattern-table layout (`PPUCTRL` bit 4 set, diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 99c63a6d2..e71c6c487 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -341,6 +341,15 @@ pub trait Mapper: Send { true } + /// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13) — force an MMC3's IRQ revision + /// (`Some`), or return to the one its header selected (`None`). Returns + /// whether this board is an MMC3 that applied it; every other board + /// ignores it (the default). Lets an iNES 1.0 dump, which cannot name its + /// MMC3 revision, run under the alternate (`Nec`) behaviour. + fn set_mmc3_revision_override(&mut self, _revision: Option) -> bool { + false + } + /// Write a byte to the PPU address space `$0000-$3FFF`. fn ppu_write(&mut self, addr: u16, value: u8); diff --git a/crates/rustynes-ppu/src/emphasis.rs b/crates/rustynes-ppu/src/emphasis.rs index c0511672e..7ba62a0f1 100644 --- a/crates/rustynes-ppu/src/emphasis.rs +++ b/crates/rustynes-ppu/src/emphasis.rs @@ -59,8 +59,11 @@ //! two cannot drift. The `MiSTer` core ships the same resulting colours, checked //! entry by entry by its `palette-gate`. //! -//! Not modelled, by choice: the page's differential phase distortion, colour -//! artifacts between pixels, and the PAL/Dendy swap of the red and green bits. +//! Not modelled, by choice: the page's differential phase distortion and +//! colour artifacts between pixels. The PAL/Dendy swap of the red and green +//! bits is modelled since v3.1.0 (`T-PAL-EMPHASIS`), where it belongs: in the +//! PPU's emphasis index (`Ppu::emit_pixel`), not in this table, which is +//! indexed by the physical tint. /// The page's terminated levels in volts: `[plain, attenuated][low, high][row]`. const LEVELS: [[[f64; 4]; 2]; 2] = [ diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index e542ea3a2..2427f7965 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -5245,7 +5245,17 @@ impl Ppu { // call for both the 2C02 composite default and the Vs./PC10 RGB // palettes) and store all four bytes with one bounds-checked slice // copy instead of four indexed stores. - let emph = usize::from((self.mask.bits() >> 5) & 0x07); + // v3.1.0 (`T-PAL-EMPHASIS`): the index is the PHYSICAL tint (bit 0 + // red, bit 1 green, bit 2 blue). PPUMASK bit 5 is red on the NTSC 2C02 + // and GREEN on the PAL 2C07 and the Dendy, bit 6 the reverse (NESdev + // "Colour emphasis"), so those two exchange off NTSC. The region is + // fixed per console, so the branch is constant. + let raw = (self.mask.bits() >> 5) & 0x07; + let emph = usize::from(if matches!(self.region, PpuRegion::Ntsc) { + raw + } else { + (raw & 0b100) | ((raw & 0b001) << 1) | ((raw & 0b010) >> 1) + }); let lut_idx = (emph << 6) | usize::from(final_idx); let rgba = self.rgba_lut[lut_idx]; self.framebuffer[off..off + 4].copy_from_slice(&rgba); @@ -7038,6 +7048,57 @@ mod tests { (ppu, TestBus::new()) } + /// v3.1.0 (`T-PAL-EMPHASIS`, ACC-01): on the PAL (2C07) and Dendy PPUs + /// PPUMASK bits 5 and 6 swap meaning. `NESdev` "Colour emphasis": "Bit 5 + /// emphasizes red on the NTSC PPU, and green on the PAL & Dendy PPUs. Bit 6 + /// emphasizes green on the NTSC PPU, and red on the PAL & Dendy PPUs. Bit 7 + /// emphasizes blue on the NTSC, PAL, & Dendy PPUs." The emphasis index the + /// renderer and the composite filters receive is the PHYSICAL tint (bit 0 + /// red, bit 1 green, bit 2 blue), so on PAL / Dendy it is the mask's bits + /// with 5 and 6 exchanged. + #[test] + fn pal_and_dendy_swap_the_red_and_green_emphasis_bits() { + let cases = [ + (PpuMask::EMPHASIZE_RED, 0b001u16, 0b010u16), + (PpuMask::EMPHASIZE_GREEN, 0b010, 0b001), + (PpuMask::EMPHASIZE_BLUE, 0b100, 0b100), + ( + PpuMask::EMPHASIZE_RED | PpuMask::EMPHASIZE_BLUE, + 0b101, + 0b110, + ), + ]; + for region in [PpuRegion::Ntsc, PpuRegion::Pal, PpuRegion::Dendy] { + for (mask, ntsc, swapped) in cases { + let mut p = Ppu::new(region); + p.post_reset_mask_remaining = 0; + p.mask = mask; // rendering off: the pixel is the backdrop + p.palette_ram[palette_index(0x3F00)] = 0x21; + p.scanline = 10; + p.dot = 20; + p.emit_pixel(); + let got = p.index_framebuffer[10 * 256 + 19]; + let want_emph = if region == PpuRegion::Ntsc { + ntsc + } else { + swapped + }; + assert_eq!( + got, + (want_emph << 6) | 0x21, + "{region:?}, mask {:#04x}: emphasis index", + mask.bits() + ); + let off = (10usize * 256 + 19) * 4; + assert_eq!( + &p.framebuffer[off..off + 4], + &p.rgba_lut[usize::from(got)], + "{region:?}: the RGBA pixel follows the same index" + ); + } + } + } + // F1.1 (Fathom accuracy remediation) — palette backdrop-override. // When rendering is disabled and the VRAM address `v` points into palette // space ($3F00-$3FFF), the palette's shared address input is driven by `v`, diff --git a/crates/rustynes-test-harness/tests/mmc3.rs b/crates/rustynes-test-harness/tests/mmc3.rs index 464af2742..e2e0e2982 100644 --- a/crates/rustynes-test-harness/tests/mmc3.rs +++ b/crates/rustynes-test-harness/tests/mmc3.rs @@ -129,8 +129,70 @@ fn mmc3_test_2_5_mmc3() { assert_eq!(s, 0, "mmc3_test_2 5-MMC3 failed: {m}"); } +/// v3.1.0 (`T-MMC3-NEC-OVERRIDE`, ACC-13): sub-ROM 6 tests the alternate +/// IRQ behaviour (MMC3A / non-Sharp MMC3B), which the default (Sharp) fails by +/// design. Under the override it passes; and sub-ROM 5 (Sharp-only) then +/// fails, which proves the override changed the behaviour rather than +/// leaving both on the default. #[test] -#[ignore = "by-design fail: sub-ROM 6 is NEC rev B; project defaults to Sharp rev A (sub-ROM 5)"] +fn mmc3_test_2_6_passes_under_the_alternate_revision_override() { + use rustynes_core::rustynes_mappers::Mmc3Revision; + let run_with = |rom: &str| { + let path = rom_path(rom); + let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + rustynes_test_harness::run_nes_blargg_with(&bytes, 600, &|nes| { + assert!( + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)), + "a mapper-4 board applies the override" + ); + }) + .expect("runs") + }; + let alt = run_with("blargg/mmc3_test_2/6-MMC3_alt.nes"); + assert_eq!( + alt.status, 0, + "6-MMC3_alt under the override: {}", + alt.message + ); + let sharp = run_with("blargg/mmc3_test_2/5-MMC3.nes"); + assert_ne!( + sharp.status, 0, + "5-MMC3 (Sharp only) passed under the alternate override, so the override did nothing" + ); +} + +/// The override is configuration: a power cycle rebuilds the board from its +/// header and the override is re-applied; clearing it returns the header's. +#[test] +fn the_mmc3_override_survives_a_power_cycle_and_clears() { + use rustynes_core::rustynes_mappers::Mmc3Revision; + let path = rom_path("blargg/mmc3_test_2/5-MMC3.nes"); + let bytes = std::fs::read(&path).expect("read"); + let mut nes = rustynes_core::Nes::from_rom(&bytes).expect("parse"); + assert!( + nes.mapper_info().name.contains("Sharp"), + "{}", + nes.mapper_info().name + ); + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)); + assert!(nes.mapper_info().name.contains("Nec")); + nes.power_cycle(); + assert!( + nes.mapper_info().name.contains("Nec"), + "the rebuilt board kept the override: {}", + nes.mapper_info().name + ); + nes.set_mmc3_revision_override(None); + assert!( + nes.mapper_info().name.contains("Sharp"), + "back to the header's" + ); +} + +#[test] +#[ignore = "by-design fail at the default: sub-ROM 6 is the alternate MMC3 IRQ revision; \ + the default is Sharp (sub-ROM 5). Runs under the override in \ + mmc3_test_2_6_passes_under_the_alternate_revision_override"] fn mmc3_test_2_6_mmc3_alt_strict() { let (s, m, _) = run("blargg/mmc3_test_2/6-MMC3_alt.nes", 600); assert_eq!(s, 0, "mmc3_test_2 6-MMC3_alt: {m}"); diff --git a/crates/rustynes-test-harness/tests/roster_boards.rs b/crates/rustynes-test-harness/tests/roster_boards.rs index c65f39b6a..2b7a2b5d3 100644 --- a/crates/rustynes-test-harness/tests/roster_boards.rs +++ b/crates/rustynes-test-harness/tests/roster_boards.rs @@ -610,6 +610,13 @@ fn gtrom_flash_is_a_battery_save() { /// disables IRQ", `NES_2_0_submappers.md`); submapper 0 is the Sharp one, which /// fires every scanline at a latch of 0. Until v2.9.6 the two were swapped with /// submapper 1. +/// +/// v3.1.0: "disables" is the submapper table's shorthand. `MMC3.md` is exact: +/// the NEC part "generates only a single IRQ when `$C000` is `$00`", and +/// "writing to `$C001` with `$C000` still at `$00` will result in another +/// single IRQ" (blargg's `6-MMC3_alt`: "IRQ should be set when reloading due +/// to clear"). The arm sequence writes `$C001` once, so NEC gives exactly one +/// IRQ and then none; before v3.1.0 this test asserted zero. #[test] fn mmc3_submapper_4_is_nec_and_0_is_sharp() { let arm = [ @@ -632,7 +639,11 @@ fn mmc3_submapper_4_is_nec_and_0_is_sharp() { .run(0) .1 }; - assert_eq!(run(4), 0, "NEC: a latch of 0 stops IRQs"); + assert_eq!( + run(4), + 1, + "NEC: a latch of 0 gives the one IRQ the $C001 write produces, then stops" + ); assert!(run(0) > 0, "Sharp: a latch of 0 fires every clock"); } diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index f542ae3f4..427629d8c 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -502,6 +502,13 @@ const CHIPS: &[Chip] = &[ "config: the v3.1.0 CPU-multiplier overclock, re-applied by the host and \ carried in `HardwareOptions`, like the extra-scanline overclock", ), + ( + "mmc3_revision_override", + "config: the v3.1.0 MMC3 IRQ-revision setting, re-applied by the host and \ + carried in `HardwareOptions`; the bus keeps it only to re-apply it to the \ + mapper a power cycle rebuilds. A restore loads into the live mapper, whose \ + revision the override already set", + ), // --- Opt-in hardware knobs, re-applied by the host on load. ( "power_on_ram", diff --git a/docs/frontend.md b/docs/frontend.md index 5c4cf770b..066e7c2fc 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -740,6 +740,20 @@ waterfall/dither transparency tricks. Off by default (a deliberate visual choice, re-blessed like the generated palette); the default framebuffer + `visual_regression` corpus stay byte-identical. +**Differential phase distortion (v3.1.0, `T-COMPOSITE-ARTIFACTS`, ACC-02).** The +signal-decode pass's sixth knob, `diff_phase` (degrees per palette row, default +**0 = off**), rotates the demodulated hue by that angle times the centre pixel's +palette row (0-3), in the direction of a delay. NESdev "NTSC video": the PPU's +level-dependent output impedance delays the chroma phase more at brighter +levels, "about 2.5° (2C02E) or 5° (2C02G) of additional rotation for each row of +the palette". Greys carry no chroma and are unaffected. The other composite +artifact the page describes, colour error between neighbouring pixels (8 +clocks per pixel against a 12-clock colour cycle), is what this pass already +reproduces by decoding the true signal, so v3.1.0 adds only the distortion. +Presentation only: no emulated byte changes, so no epoch rise. The mobile and +web hosts write the knob as 0 and do not expose it; the CPU Bisqwit filter +does not model it. + **Vs. `DualSystem` two-screen presentation (v2.1.2 F2.1).** A loaded Vs. `DualSystem` cabinet (Balloon Fight / Wrecking Crew / Tennis / Baseball) runs both cross-wired consoles and presents them together. The core dual engine @@ -1025,8 +1039,9 @@ Per-tab content the panel sections render (`debugger/settings_panel.rs`): netplay drive sites call `force_stock_timing` before a tick, because every peer must run the same timeline. A Vs. DualSystem cabinet keeps stock timing (ADR 0032 scopes enhancements out of dual mode). The test harness builds its - own `Nes` and never sets it. **Disable sprite limit** is still inert: the core - has no hook for it. The **Accuracy** group above it carries OAM decay and, + own `Nes` and never sets it. **Disable sprite limit** reaches the core since + v3.1.0 (`Nes::set_sprite_limit_disabled`, applied beside the v3.1.0 CPU + overclock outside a movie session; until then the setting was inert). The **Accuracy** group above it carries OAM decay and, from v2.9.8, **Famicom console (PPU leaves reset early)** — `[emulation] famicom_console` (default `false`, the NES model, byte-identical). `console_model_for` maps it to `rustynes_core::ConsoleModel`; diff --git a/docs/mappers.md b/docs/mappers.md index 931a2f0bf..1e771310f 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -295,7 +295,7 @@ Sorted by number of commercial titles using each mapper. | 1 | 1-5 | MMC1 (SUROM, SXROM, etc.) | 2 | — | — | landed (Phase 2) | Serial 5-write protocol; consecutive-write bug. On boards with at most 8 KiB of CHR (v2.7.2, from `nesdev_wiki/MMC1.xhtml`), the CHR bank register's bit 4 selects the 256 KiB PRG half for the whole window, fixed bank included (SUROM / SXROM), and bits 3-2 select the 8 KiB PRG-RAM bank (SOROM: bit 3; SXROM: bit 3 = A14, bit 2 = A13). In 4 KiB CHR mode the driving register is the one the last CHR fetch selected. SNROM's bit-4 RAM enable applies only to <= 256 KiB PRG with <= 8 KiB RAM. holy_mapperel `M1_P512K_CR8K_S8K` / `_S32K` pass `0000`. SZROM is not modelled. | | 2 | 0-2 | UxROM | 2 | — | — | landed (Phase 2) | UNROM, UOROM, etc. CHR-RAM only. | | 3 | 0-2 | CNROM | 2 | — | — | landed (Phase 2) | Bus conflict required. | -| 4 | 0-3 | MMC3 (and MMC6, sub 1) | 4 | — | A12 | landed (Phase 4 / S1) | Sharp vs NEC IRQ revision; default Sharp. The IRQ line is raised at the first per-cycle hook after the A12 rise (v2.9.9, pitfall 2). `mmc3_test/5-MMC3` and `mmc3_test_2/5-MMC3` pass; both `4-scanline_timing` ROMs pass all 13 sub-tests since T-MMC3-BG-A12, a PPU change (`docs/ppu-2c02.md` pitfall 4); they failed at sub-test 9 from v2.9.9. | +| 4 | 0-3 | MMC3 (and MMC6, sub 1) | 4 | — | A12 | landed (Phase 4 / S1) | Sharp vs NEC IRQ revision; default Sharp. v3.1.0: `Nes::set_mmc3_revision_override` selects the alternate (NEC) revision for any mapper-4 ROM, and its `$C001` reload to 0 now asserts as documented, so `mmc3_test_2/6-MMC3_alt` passes under it. The IRQ line is raised at the first per-cycle hook after the A12 rise (v2.9.9, pitfall 2). `mmc3_test/5-MMC3` and `mmc3_test_2/5-MMC3` pass; both `4-scanline_timing` ROMs pass all 13 sub-tests since T-MMC3-BG-A12, a PPU change (`docs/ppu-2c02.md` pitfall 4); they failed at sub-test 9 from v2.9.9. | | 5 | — | MMC5 | 4 | yes (landed) | scanline | v0+v1 landed (Phase 4 / S4) | Banking + scanline IRQ + ExRAM modes 10/11 + multiplier (v0). Fill mode (`$5106`/`$5107`), dual sprite/BG CHR registers used for sprite tile fetches, ExGrafix per-tile attribute + CHR override (mode 01) (v1). Vertical split-screen (`$5200-$5202`) via `bg_split_state` (Castlevania III J status bar) and the MMC5 audio extension (two pulse + 7-bit PCM, `$5000-$5015`, behind the default-on `mapper-audio` feature) landed. PRG-RAM banking (v2.7.2, `nesdev_wiki/MMC5.xhtml` §"PRG-RAM configurations"): `$5113` and RAM-mode `$5114-$5116` page the RAM by the bank value's low three bits over the wiki's 64 KiB "compatible superset for all games", because PRG-RAM sizes in headers are unreliable (*L'Empereur*'s NES 2.0 dump under-declares its ETROM board); a 16 KiB window takes A13 from the CPU. `sram()` is the battery-backed part of the header's declared RAM: all of it, except ETROM's 16 KiB, where only the first chip is saved. | | 7 | 0-2 | AxROM | 2 | — | — | landed (Phase 2) | Single-screen mirroring control. | | 9 | — | MMC2 | 4 | — | — | landed (Phase 4 / S2) | Punch-Out; latched CHR per fetch ($FD/$FE). | diff --git a/docs/ppu-2c02.md b/docs/ppu-2c02.md index 39d3e63c4..94520ee20 100644 --- a/docs/ppu-2c02.md +++ b/docs/ppu-2c02.md @@ -656,6 +656,15 @@ change core rendering tests. PPUMASK bit 0 (greyscale): output color ANDed with `$30`. Bits 7-5 (BGR emphasis) are applied through the 512-entry `rgba_lut`, per pixel during emission. +**PAL and Dendy swap bits 5 and 6 (v3.1.0, `T-PAL-EMPHASIS`).** NESdev "Colour emphasis": +bit 5 emphasises red on the NTSC 2C02 and **green** on the PAL 2C07 and the Dendy, bit 6 the +reverse, and bit 7 is blue on all three. `emit_pixel` therefore forms the emphasis index as the +PHYSICAL tint (bit 0 red, bit 1 green, bit 2 blue), exchanging bits 5 and 6 off NTSC, and +both framebuffers carry it, so the composite filters see the right tint as well. Until +v3.1.0 every PAL or Dendy game that set emphasis showed the wrong colour. Pinned by +`pal_and_dendy_swap_the_red_and_green_emphasis_bits`. The MiSTer core has no PAL mode, so +there is no RTL counterpart. + **Emphasis model (v2.9.8, `T-EMPHASIS-MODEL`).** The hardware has one attenuator shared by the three bits, armed during the phases of colours `$C`, `$4` and `$8` for bits 5, 6 and 7, so it runs 6, 10 or 12 of the 12 colour phases for one, two or three bits, and it never diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 730544f3d..93186d61d 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1448,9 +1448,9 @@ re-minted: `T-PS-dual-runahead` (ADR 0032), `T-MISTER-SAVESTATE`, | `T-EPOCH-FINGERPRINT` | A committed panel of output hashes that fails CI when it moves without an `EMULATION_EPOCH` rise. **DONE v3.1.0** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment) | CI-02 | v3.1.0 | | `T-CPU-OVERCLOCK` | The CPU-multiplier overclock, in `HardwareOptions`, movies and netplay (D22) **DONE v3.1.0** | FE-01 | v3.1.0 | | `T-SPRITE-LIMIT` | "Disable sprite limit", render-only, carried like the overclock (D22) **DONE v3.1.0** | FE-02 | v3.1.0 | -| `T-PAL-EMPHASIS` | The PAL/Dendy emphasis red/green swap | ACC-01 | v3.1.0 | -| `T-COMPOSITE-ARTIFACTS` | Differential phase distortion and inter-pixel artifacts, an opt-in video option (D20) | ACC-02 | v3.1.0 | -| `T-MMC3-NEC-OVERRIDE` | The NEC rev B MMC3 as a per-game or config override (D20) | ACC-13 | v3.1.0 | +| `T-PAL-EMPHASIS` | The PAL/Dendy emphasis red/green swap **DONE v3.1.0** | ACC-01 | v3.1.0 | +| `T-COMPOSITE-ARTIFACTS` | Differential phase distortion and inter-pixel artifacts, an opt-in video option (D20) **DONE v3.1.0** (differential phase; the inter-pixel artifacts already existed in the signal-decode pass) | ACC-02 | v3.1.0 | +| `T-MMC3-NEC-OVERRIDE` | The NEC rev B MMC3 as a per-game or config override (D20) **DONE v3.1.0** | ACC-13 | v3.1.0 | | `T-MAPPER-BREADTH-V3` | The missing families with real titles, both cores (D26) | MAP-03, MAP-05, FB-1 | v3.2.0, v3.8.0 | | `T-KNOWN-BLANK` | Triage the 52 KNOWN_BLANK dumps | ACC-08, ACC-09 | v3.2.0 | | `T-CURATED-EVIDENCE` | BestEffort → Curated on local-dump evidence under D23's record | MAP-01 | v3.2.0 | diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index a907c5352..91dfb27f4 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -93,3 +93,6 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | 3 | **Done.** `Nes::set_cpu_overclock(1..=4)`: the CPU divider is divided; the APU, DMC, mapper cycle hooks and PPU timers run on "stock steps" from a master-clock debt (`overclock_debt`, `apu_cycle`, BUS section 3); `run_frame`'s budget scales. `x1` byte-identical (epoch gate). Desktop: Settings combo, recorded by movies and matched by netplay (not held at stock like the scanline overclock). Not done: libretro and mobile options (neither exposes the scanline overclock either) | `cpu_overclock.rs` 6/6; mutants: every cycle a stock step (2 tests fail), `apu_cycle` parity flip and dropped debt on restore (snapshot test fails, after moving it to an audible ROM: ny2011 is silent for 300 frames and made it blind) | | 4 | **Done.** Render-only extras: up to 56 sprites fetched after the eighth real fetch, drawn behind the hardware eight, PPU snapshot v13. `Mapper::chr_reads_are_pure` (5 impure boards found by scanning every `ppu_read` body; pinned for every constructible id against `save_state`). Desktop checkbox now live | `sprite_limit.rs` 5/5 (RAM, audio, cycles identical frame by frame; blargg sprite_overflow 1-5 pass with it on); stimulus built in the test (spritecans never exceeds 7 per line, NEStress's 62 are transparent); mutants: extras not drawn, extras dropped on restore, MMC2 / J.Y. ASIC claiming purity, all caught | | 5 | **Done.** `.rnm` format 6 (minimum 6), netplay protocol 7 (`"RNE7"`, same `Sync` layout; `"RNE6"` refused naming its epoch); ADR 0044 amended | `a_format_5_movie_is_refused_as_too_old`, `a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch` | +| 6 | **Done.** `emit_pixel` forms the emphasis index as the physical tint, exchanging PPUMASK bits 5/6 off NTSC (NESdev "Colour emphasis"), for both framebuffers. NTSC unchanged; the sibling has no PAL | `pal_and_dendy_swap_the_red_and_green_emphasis_bits` red first (PAL gave index 97, red, for bit 5) | +| 7 | **Done (scoped).** The inter-pixel colour artifacts were already reproduced by the raw signal-decode pass (v2.1.9); added the missing differential phase distortion there, as an opt-in knob (`diff_phase`, deg/row, default 0) in `signal_decode.wgsl`. Desktop only; mobile/web write 0; the Bisqwit CPU filter does not model it | `crt_stack_shaders_parse_and_validate` (naga, the wgpu front end) passes on the changed shader; the param test pins the knob and its 0 default. NOT shown on screen by an automated test: no GPU-output test exists for this pass, so the visual check is the maintainer's | +| 8 | **Done.** `Mapper::set_mmc3_revision_override`, `Nes` / `HardwareOptions` (`mmc3_revision`), re-applied after a power-cycle rebuild; desktop `[emulation] mmc3_irq_revision`. Running `mmc3_test_2/6` under it FOUND a defect: the alternate revision did not assert on a `$C001` reload to 0 ("IRQ should be set when reloading due to clear"), which MMC3.md documents; fixed, Sharp unchanged | `mmc3_test_2_6_passes_under_the_alternate_revision_override` (and 5 then fails, proving the override acts); `the_mmc3_override_survives_a_power_cycle_and_clears`; all 20 MMC3 tests pass. Two older tests pinned the opposite (NEC silent on the reload) and were corrected with the wiki's text: `nec_asserts_once_on_a_c001_reload_to_zero_and_not_after`, and `mmc3_submapper_4_is_nec_and_0_is_sharp` now expects the single IRQ | From 8a680bd1d9a12c275f6c26df6bc0138bdc99d867 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 21:21:08 -0400 Subject: [PATCH 06/21] feat(dual): rewind and run-ahead on the Vs. DualSystem cabinet v3.1.0 Sprint 4, plan item 9 (T-PS-dual-runahead, FE-09, decision D27), the work ADR 0032's 2026-10-07 amendment decided. Until now a two-console cabinet ignored the rewind key and the run-ahead setting: produce_dual_frame never looked at either. The unit of rewind and run-ahead is the CABINET, never one console. The two consoles share a 2 KiB WRAM and drive each other's /IRQ through the $4016 bit-1 latch, so stepping one back, or running one ahead, without the other produces a cabinet from two timelines. Everything is therefore built on the existing "RVSD" container, which snapshots both consoles and the latch and restores them all-or-nothing. Core (`VsDualSystem`): - The cabinet owns a `RewindRing` (`enable_rewind[_with]`, `disable_rewind`, `rewind_len`, `set_rewind_capture`, `rewind_capture`, `rewind_step_back`, `rewind_clear`). `run_frame` pushes a capture after every frame while capture is on; the consoles' own rings stay disabled. - Entries are whole RVSD containers with BOTH framebuffers. The single console's ring stores slim entries and re-renders the picture after a step back by running a frame and restoring again; for the cabinet that would run the five-cycle soft lockstep forward and back on every step. Keeping the framebuffers costs two 245,760-byte buffers per entry before the ring's XOR delta and LZ4, and makes a step back exact for both screens with one restore. - `restore_quiet` is the restore that keeps the ring: each console restored with `Nes::restore_quiet` (no timeline-generation bump, no rewind clear). Run-ahead's rollback and a rewind step use it. `restore` (a loaded state) and `power_cycle` now empty the ring, because both replace the timeline it describes, as `Nes::restore` treats its own ring. `restore` and `restore_quiet` share one `restore_inner`, so the v2.9.0 all-or-nothing rollback across both consoles applies to both. Frontend: - `RunAhead::run_cabinet_ahead` / `finish_cabinet`: the single-console cycle on the cabinet. The persistent frame (captured by the ring), a snapshot, n-1 hidden frames and the visible one with capture off, then a quiet rollback. Hidden frames drain and discard both consoles' audio. - `produce_dual_frame` takes the frame inputs. Rewind held: step back and present both restored screens, no audio pushed, coin latch untouched (as the single path). Run-ahead (native only, as the single path): present the visible frame's two screens and main audio, then roll back. The depth goes through `effective_run_ahead`, so the budget throttle applies to cabinets too. - `rewind_budget(&Config)` is the one definition of the ring's byte budget and keyframe period. It was written out at three sites and the cabinet made a fourth; the load paths, the settings toggle and `build_dual_cabinet` all call it, and the settings toggle now reaches a loaded cabinet as well as a console. The gate, both clauses of the amendment, each shown able to fail: - `vs_dualsystem_rewind.rs` (6 tests): every step back across ten frames lands on that frame's two framebuffers and whole cabinet byte for byte, across keyframes and deltas; play resumed after a rewind replays the same frames; capture-off frames stay out of the ring; a loud restore and a power cycle empty it and a quiet restore keeps it. Mutants: the sub console's block captured SLIM (state kept, picture dropped), CAUGHT by the both-framebuffers assertion; a loud restore in the step back, CAUGHT by two tests. - `cabinet_runahead_matches_a_plain_run_on_both_screens` at depths 1 and 2: after every cycle the persistent cabinet's snapshot equals a plain run's, both visible screens equal the plain run n frames on, and the ring holds only persistent frames. Mutants: no rollback, CAUGHT; capture left on across hidden frames, CAUGHT (80 entries against the plain run's). - `a_cabinet_rewinds_and_runs_ahead_through_the_produce_path`, through `EmuCore`'s real produce path. Mutants: rewind ignored, CAUGHT; run-ahead ignored, CAUGHT. The stimulus is a cart built for it, and that matters. The protocol cart in `vs_dualsystem_synth.rs` never turns rendering on, so both of its screens are one unchanging colour and a framebuffer comparison against it passes whatever a restore does to the pictures. The new cart enables background rendering over blank CHR-RAM and writes a per-frame counter into $3F00 in each console's NMI: eight colours, $10-$17 on the main and $20-$27 on the sub. The first version used a full 64-entry counter and the run-ahead test's own sanity check failed on it: the palette's blacks ($xD-$xF) come three in a row, so consecutive frames could be identical. Same cart in both test files (the frontend's copy is `runahead::tests::flashing_cabinet`). Out of scope, unchanged: netplay and TAS (`T-PS-dual-netplay`), the debugger and HD packs stay single-console; a cabinet keeps stock timing. ADR 0032's amendment gains an outcome note; docs/frontend.md, docs/compatibility.md and the accuracy ledger no longer list rewind and run-ahead as excluded. Verification: cargo test --release --workspace --features test-roms: 3,262 passed, 0 failed, 11 ignored. fmt; clippy -D warnings for the workspace, scripting, scripting+hd-pack, retroachievements and full; both wasm32 clippy builds; rustdoc -D warnings; the no_std build. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 6 + crates/rustynes-core/src/vs_dualsystem.rs | 159 +++++++++++- crates/rustynes-frontend/src/app.rs | 66 +++-- crates/rustynes-frontend/src/emu.rs | 126 +++++++++- crates/rustynes-frontend/src/runahead.rs | 185 +++++++++++++- .../tests/vs_dualsystem_rewind.rs | 234 ++++++++++++++++++ docs/accuracy-ledger.md | 2 +- ...0032-vs-dualsystem-desktop-presentation.md | 13 + docs/compatibility.md | 5 +- docs/frontend.md | 18 +- to-dos/ROADMAP.md | 2 +- to-dos/plans/v3.1.0-plan.md | 1 + 12 files changed, 772 insertions(+), 45 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bd504a2d..1668593eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,12 @@ cycle-accurate core later replaced. run any mapper-4 game on the Sharp or the alternate (MMC3A / NEC) chip, for dumps whose header cannot say which. blargg's `mmc3_test_2/6-MMC3_alt` passes under the alternate setting. Carried in movies and netplay. +- **Rewind and run-ahead on the Vs. DualSystem cabinet** (`T-PS-dual-runahead`, + ADR 0032 amended): both work in two-screen mode, on the whole cabinet, so + the two consoles never fall out of step. A step back restores both screens + exactly, and run-ahead shows the same frames a run without it would, a + frame or more sooner. Netplay, movies, the debugger and HD packs stay + single-console. - **A test now enforces the emulation-epoch rule** (`T-EPOCH-FINGERPRINT`). It fingerprints seven test ROMs (frames, audio, RAM, CPU cycles) and fails when that output moves while `EMULATION_EPOCH` still equals the last diff --git a/crates/rustynes-core/src/vs_dualsystem.rs b/crates/rustynes-core/src/vs_dualsystem.rs index 97c57ef8f..9425cc7fc 100644 --- a/crates/rustynes-core/src/vs_dualsystem.rs +++ b/crates/rustynes-core/src/vs_dualsystem.rs @@ -64,6 +64,7 @@ use alloc::boxed::Box; use alloc::vec::Vec; use crate::nes::Nes; +use crate::rewind::{REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindRing}; use crate::save_state::SnapshotError; use rustynes_mappers::RomError; @@ -97,6 +98,25 @@ pub struct VsDualSystem { /// two blocks cannot be encoded straight into the container; they pass /// through this instead. Not state: never serialized, never compared. block_scratch: Vec, + /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032 amendment of 2026-10-07) — the + /// cabinet's rewind ring, holding whole-cabinet `RVSD` containers. + /// `None` (the default) until a frontend opts in with + /// [`Self::enable_rewind`]. + /// + /// The ring lives on the cabinet, not on either console, because the unit + /// of rewind is the cabinet: the two consoles share a 2 KiB WRAM and drive + /// each other's `/IRQ`, so stepping one back without the other produces a + /// cabinet from two timelines. Each console's own [`Nes`] ring stays + /// disabled. Not state: never serialized, never compared. + rewind: Option, + /// When `false`, [`Self::run_frame`] skips the rewind capture. Run-ahead + /// clears it across its hidden and visible frames, so the ring holds the + /// persistent timeline only (the same contract as + /// [`Nes::set_rewind_capture`]). + rewind_capture_enabled: bool, + /// Reused buffer for the per-frame rewind capture (a cabinet container is + /// about 520 KB before the ring compresses it). + rewind_snap_buf: Vec, } impl VsDualSystem { @@ -151,6 +171,9 @@ impl VsDualSystem { sub_bit1: false, comms_scratch: Vec::new(), block_scratch: Vec::new(), + rewind: None, + rewind_capture_enabled: true, + rewind_snap_buf: Vec::new(), }; dual.wire(); dual @@ -264,6 +287,10 @@ impl VsDualSystem { // underlying `Nes` (none today) never observe a stale latch. let _ = self.main.bus_mut().take_frame_complete(); let _ = self.sub.bus_mut().take_frame_complete(); + // v3.1.0 — push the completed frame into the cabinet's rewind ring. + if self.rewind.is_some() && self.rewind_capture_enabled { + self.rewind_capture(); + } } /// The main console's 256x240 RGBA8 framebuffer (the left screen). @@ -368,6 +395,8 @@ impl VsDualSystem { self.comms_scratch.clear(); self.block_scratch.clear(); self.wire(); + // The ring describes the timeline the power cycle just ended. + self.rewind_clear(); } /// Serialize the dual system: a versioned container nesting the two @@ -434,7 +463,31 @@ impl VsDualSystem { /// Returns [`SnapshotError`] on a bad container or when either nested /// console snapshot fails to restore. Both consoles are then unchanged /// (since v2.9.0; before it a rejected sub block left main restored). + /// + /// A successful restore empties the cabinet's rewind ring (v3.1.0): the + /// entries describe the timeline the restore replaced, as + /// [`Nes::restore`] treats its own ring. pub fn restore(&mut self, data: &[u8]) -> Result<(), SnapshotError> { + self.restore_inner(data, false)?; + self.rewind_clear(); + Ok(()) + } + + /// v3.1.0 (`T-PS-dual-runahead`) — [`Self::restore`] without its + /// side effects: the cabinet's rewind ring is kept, and each console is + /// restored with [`Nes::restore_quiet`] (no timeline-generation bump, no + /// rewind clear). For restores that return to the cabinet's own + /// timeline rather than replace it: run-ahead's rollback and a rewind + /// step. Same container, same all-or-nothing contract. + /// + /// # Errors + /// + /// As [`Self::restore`]. + pub fn restore_quiet(&mut self, data: &[u8]) -> Result<(), SnapshotError> { + self.restore_inner(data, true) + } + + fn restore_inner(&mut self, data: &[u8], quiet: bool) -> Result<(), SnapshotError> { // A malformed dual container reports as an unsupported format with // the container version we could read (0 when even the header is // short) — the closest fit among the existing error variants until @@ -488,8 +541,15 @@ impl VsDualSystem { // restores are. let mut main_backup = Vec::new(); self.main.snapshot_core_into(&mut main_backup); - self.main.restore(main_block)?; - if let Err(e) = self.sub.restore(sub_block) { + let restore = |nes: &mut Nes, block: &[u8]| { + if quiet { + nes.restore_quiet(block) + } else { + nes.restore(block) + } + }; + restore(&mut self.main, main_block)?; + if let Err(e) = restore(&mut self.sub, sub_block) { let rolled_back = self.main.restore_quiet(&main_backup); debug_assert!( rolled_back.is_ok(), @@ -520,6 +580,101 @@ impl VsDualSystem { self.main.bus_mut().set_vs_dual_wram(wram); Ok(()) } + + /// v3.1.0 (`T-PS-dual-runahead`) — enable the cabinet's rewind ring with + /// the default byte budget and keyframe period. + pub fn enable_rewind(&mut self) { + self.enable_rewind_with(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD); + } + + /// Enable the cabinet's rewind ring with an explicit byte budget and + /// keyframe period. Replaces (and so empties) any ring already enabled. + pub fn enable_rewind_with(&mut self, max_bytes: usize, keyframe_period: u32) { + self.rewind = Some(RewindRing::new(max_bytes, keyframe_period)); + } + + /// Disable the cabinet's rewind ring and free its memory. + pub fn disable_rewind(&mut self) { + self.rewind = None; + self.rewind_snap_buf = Vec::new(); + } + + /// `true` if the cabinet's rewind ring is enabled. + #[must_use] + pub const fn rewind_enabled(&self) -> bool { + self.rewind.is_some() + } + + /// Number of buffered rewind entries (0 when rewind is disabled). + #[must_use] + pub fn rewind_len(&self) -> usize { + self.rewind.as_ref().map_or(0, RewindRing::len) + } + + /// Turn the per-frame rewind capture in [`Self::run_frame`] on or off. + /// Run-ahead turns it off across its hidden and visible frames, which + /// are not the cabinet's timeline. + pub const fn set_rewind_capture(&mut self, enabled: bool) { + self.rewind_capture_enabled = enabled; + } + + /// `true` while [`Self::run_frame`] captures into the rewind ring. + #[must_use] + pub const fn rewind_capture_enabled(&self) -> bool { + self.rewind_capture_enabled + } + + /// Push the cabinet's current state into the rewind ring, keyed by the + /// main console's frame. [`Self::run_frame`] calls this after every + /// frame while capture is on; a no-op while rewind is disabled. + /// + /// Each entry is a whole `RVSD` container ([`Self::snapshot_into`]): + /// both consoles, framebuffers included, and the bit-1 latch. Unlike the + /// single console's ring, which stores SLIM entries and re-renders the + /// picture after a step back, the cabinet keeps the framebuffers in the + /// entry. That is what makes a step back exact for BOTH screens without + /// running the cabinet forward and back: a re-render would run the + /// five-cycle soft lockstep for a frame and restore again, twice the + /// work of a single console. The cost is two 245,760-byte framebuffers + /// per entry, which the ring's XOR delta and LZ4 reduce to the pixels + /// that changed since the last keyframe. + pub fn rewind_capture(&mut self) { + if self.rewind.is_none() { + return; + } + let frame = self.main.frame(); + let mut buf = core::mem::take(&mut self.rewind_snap_buf); + self.snapshot_into(&mut buf); + if let Some(ring) = &mut self.rewind { + ring.push(frame, &buf); + } + self.rewind_snap_buf = buf; + } + + /// Pop the most recent rewind entry and restore the cabinet to it, both + /// framebuffers included. Returns `true` on success and `false` when + /// the ring is empty, rewind is disabled, or the entry fails to decode + /// or restore (the cabinet is then unchanged, per [`Self::restore`]'s + /// all-or-nothing contract). + /// + /// The restore is quiet ([`Self::restore_quiet`]): the ring survives, + /// since the user is mid-rewind. + pub fn rewind_step_back(&mut self) -> bool { + let Some(ring) = self.rewind.as_mut() else { + return false; + }; + let Some(Ok(bytes)) = ring.pop_back() else { + return false; + }; + self.restore_quiet(&bytes).is_ok() + } + + /// Drop every buffered rewind entry (the ring stays enabled). + pub fn rewind_clear(&mut self) { + if let Some(ring) = &mut self.rewind { + ring.clear(); + } + } } /// The top-level emulator: one standard console, or a Vs. `DualSystem` pair. diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index f53cd00bd..c60c1cfc7 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -432,6 +432,26 @@ fn configure_game_db_and_patch_startup_rom( apply_load_time_header_overrides(rom_bytes, Some(rom_path)); } +/// The rewind ring's byte budget and keyframe period from `[rewind]`, or +/// `None` when rewind is off. One definition for every console and cabinet +/// the frontend enables rewind on (v3.1.0; it was written out at three sites +/// before the cabinet made it four). +/// +/// The budget is `max_seconds` of 60 fps frames at about 200 KiB each, at +/// least one second's worth, and never above +/// [`rustynes_core::REWIND_DEFAULT_MAX_BYTES`]; the ring's delta encoding +/// stores far less per frame than that, so the cap is what binds in practice. +fn rewind_budget(config: &Config) -> Option<(usize, u32)> { + if !config.rewind.enabled { + return None; + } + let max_bytes = ((config.rewind.max_seconds as usize) * 60).max(60) * 200 * 1024; + Some(( + max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), + config.rewind.keyframe_period.max(1), + )) +} + /// v2.9.8 — every config-derived setting the frontend pushes into a console, /// applied in one call to a console that has not run since it was built or /// power-cycled. @@ -1678,6 +1698,9 @@ impl App { /// configuration ([`configure_console`]), before the cabinet is installed. /// Until v2.9.8 a cabinet got neither: the load paths applied both to the /// probe console, which a cabinet discards. + /// + /// v3.1.0 (`T-PS-dual-runahead`) — and the cabinet's rewind ring, from the + /// same `[rewind]` settings a single console gets ([`rewind_budget`]). fn build_dual_cabinet( &self, nes: &Nes, @@ -1696,6 +1719,9 @@ impl App { Self::apply_game_db(console, bytes); configure_console(&self.config, console); } + if let Some((max_bytes, keyframe_period)) = rewind_budget(&self.config) { + vs.enable_rewind_with(max_bytes, keyframe_period); + } Some(Box::new(vs)) } Err(e) => { @@ -2009,13 +2035,8 @@ impl App { // in `install_nes_wasm` (v2.9.7) with the same `build_dual_cabinet`. #[cfg(not(target_arch = "wasm32"))] let dual_cabinet = self.cabinet_for_image(&nes, &bytes, sample_rate); - if self.config.rewind.enabled { - let max_bytes: usize = - ((self.config.rewind.max_seconds as usize) * 60).max(60) * 200 * 1024; - nes.enable_rewind_with( - max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), - self.config.rewind.keyframe_period.max(1), - ); + if let Some((max_bytes, keyframe_period)) = rewind_budget(&self.config) { + nes.enable_rewind_with(max_bytes, keyframe_period); } // v1.7.0 — arm the Four Score 4-player adapter per config. Off by // default, so `$4016`/`$4017` reads stay byte-identical to two @@ -9699,15 +9720,8 @@ impl App { let dual_cabinet = self.cabinet_for_image(&nes, &self.rom_bytes, sample_rate); #[cfg(target_arch = "wasm32")] let _ = sample_rate; - if self.config.rewind.enabled { - // 60 fps × max_seconds × ~120 KiB/snapshot keyframe ≈ ~7 MiB - // before delta compression; we cap at 32 MiB by default. - let max_bytes: usize = - ((self.config.rewind.max_seconds as usize) * 60).max(60) * 200 * 1024; - nes.enable_rewind_with( - max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), - self.config.rewind.keyframe_period.max(1), - ); + if let Some((max_bytes, keyframe_period)) = rewind_budget(&self.config) { + nes.enable_rewind_with(max_bytes, keyframe_period); } // v1.7.0 — arm the Four Score 4-player adapter per config (off by // default; two-controller path stays byte-identical when off). @@ -11946,20 +11960,22 @@ impl ApplicationHandler for App { } if settings.rewind_enabled { let mut guard = self.emu.lock(); + let budget = rewind_budget(&self.config); if let Some(nes) = guard.nes.as_mut() { - if self.config.rewind.enabled { - let max_bytes: usize = ((self.config.rewind.max_seconds as usize) * 60) - .max(60) - * 200 - * 1024; - nes.enable_rewind_with( - max_bytes.min(rustynes_core::REWIND_DEFAULT_MAX_BYTES), - self.config.rewind.keyframe_period.max(1), - ); + if let Some((max_bytes, keyframe_period)) = budget { + nes.enable_rewind_with(max_bytes, keyframe_period); } else { nes.disable_rewind(); } } + // v3.1.0 — a loaded cabinet follows the same setting. + if let Some(dual) = guard.dual.as_mut() { + if let Some((max_bytes, keyframe_period)) = budget { + dual.enable_rewind_with(max_bytes, keyframe_period); + } else { + dual.disable_rewind(); + } + } } // v2.8.0 Phase 2 — re-resolve the pacing regime live when // the user changed `pacing_mode` in the settings panel. An diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 32d63f74b..5db894a6f 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -1610,13 +1610,14 @@ impl EmuCore { // v2.1.2 F2.1 — a loaded Vs. `DualSystem` cabinet takes a parallel, much // simpler produce path: step both consoles, harvest both framebuffers, // push the MAIN console's audio. The advanced single-`Nes` features - // (run-ahead, rewind, TAS, breakpoints, HD-pack, A/V record) are scoped - // out in dual mode (ADR 0032) — `dual` and `nes` are mutually exclusive, - // so the whole single path below is dead when a cabinet is loaded. + // (TAS, breakpoints, HD-pack, A/V record) are scoped out in dual mode + // (ADR 0032) — `dual` and `nes` are mutually exclusive, so the whole + // single path below is dead when a cabinet is loaded. Rewind and + // run-ahead are not, since v3.1.0 (the ADR's 2026-10-07 amendment). // v2.9.7 "Tandem" — the wasm-winit frontend takes this path too (it // builds the cabinet on its load path and presents both screens). if self.dual.is_some() { - self.produce_dual_frame(sinks); + self.produce_dual_frame(inputs, sinks); return fx; } let hardcore_blocked = inputs.hardcore_blocked; @@ -1899,8 +1900,18 @@ impl EmuCore { /// layout without holding the emu lock), and pushes the MAIN console's audio /// to the sink. The SUB console's audio is drained and discarded so its APU /// sample buffer cannot grow without bound. This path deliberately omits the - /// single-`Nes` machinery (run-ahead / rewind / TAS / breakpoints / HD-pack / - /// A/V record), which is scoped out in dual mode (ADR 0032). + /// single-`Nes` machinery (TAS / breakpoints / HD-pack / A/V record), which is + /// scoped out in dual mode (ADR 0032). + /// + /// v3.1.0 (`T-PS-dual-runahead`, the ADR's 2026-10-07 amendment) — rewind + /// and run-ahead work here too, on the whole cabinet. With the rewind key + /// held, the cabinet steps back one frame through its own ring + /// ([`rustynes_core::VsDualSystem::rewind_step_back`]) and presents both + /// restored screens; no audio is pushed, as on the single path. With + /// run-ahead on (native only, as on the single path), the cabinet runs + /// the persistent frame plus `n` frames ahead + /// ([`crate::runahead::RunAhead::run_cabinet_ahead`]), presents the + /// visible frame's two screens and main audio, and rolls back. /// /// v2.9.7 "Tandem" — no longer native-only: the wasm-winit frontend runs a /// cabinet too. The only platform difference is the audio sink: native @@ -1912,7 +1923,26 @@ impl EmuCore { target_arch = "wasm32", allow(unused_variables, clippy::needless_pass_by_ref_mut) )] - fn produce_dual_frame(&mut self, sinks: &mut FrameSinks<'_>) { + fn produce_dual_frame(&mut self, inputs: &FrameInputs, sinks: &mut FrameSinks<'_>) { + // Resolved before `dual` is borrowed; 0 = a plain frame. + #[cfg(not(target_arch = "wasm32"))] + let run_ahead_n = self.effective_run_ahead(inputs.run_ahead); + // RetroAchievements hardcore never reaches a cabinet (RA is scoped out + // of dual mode), and `App` folds it into `rewind_held` regardless. + if inputs.rewind_held { + let Some(dual) = self.dual.as_mut() else { + return; + }; + // A failed step (empty ring, rewind off) leaves the cabinet where + // it is; the screens are re-presented either way. + let _ = dual.rewind_step_back(); + self.present_fb.clear(); + self.present_fb.extend_from_slice(dual.main_framebuffer()); + self.present_fb_sub.clear(); + self.present_fb_sub + .extend_from_slice(dual.sub_framebuffer()); + return; + } // v2.5.0 / F2.1 — Vs. System coin latch: a coin-insert holds the acceptor // for a few frames, then auto-clears (uniform with the single path). let clear_coin = self.vs_coin_frames > 0 && { @@ -1925,13 +1955,24 @@ impl EmuCore { if clear_coin { dual.clear_coin(); } - dual.run_frame(); + #[cfg(not(target_arch = "wasm32"))] + let ran_ahead = run_ahead_n > 0 && { + self.runahead.run_cabinet_ahead(dual, run_ahead_n); + true + }; + #[cfg(target_arch = "wasm32")] + let ran_ahead = false; + if !ran_ahead { + dual.run_frame(); + } self.present_fb.clear(); self.present_fb.extend_from_slice(dual.main_framebuffer()); self.present_fb_sub.clear(); self.present_fb_sub .extend_from_slice(dual.sub_framebuffer()); // Push the MAIN console's audio; the frontend presents one audio stream. + // Under run-ahead this is the visible frame's audio, harvested before + // the rollback below, exactly as the single path does. #[cfg(not(target_arch = "wasm32"))] if let Some(audio) = sinks.audio.as_mut() { let target = ((u64::from(audio.sample_rate()) / 50) as usize).max(1024); @@ -1952,6 +1993,10 @@ impl EmuCore { self.audio_buf.resize(1024, 0.0); } while dual.sub_mut().drain_audio_into(&mut self.audio_buf) == self.audio_buf.len() {} + #[cfg(not(target_arch = "wasm32"))] + if ran_ahead { + self.runahead.finish_cabinet(dual); + } } /// v1.6.0 "Studio" Workstream G — feed this frame's produced framebuffer @@ -2749,6 +2794,71 @@ mod tests { ); } + /// v3.1.0 (`T-PS-dual-runahead`) — rewind and run-ahead reach a cabinet + /// through the real produce path, not only through `VsDualSystem` and + /// `RunAhead` directly. Holding rewind steps the cabinet back and presents + /// BOTH restored screens; a frame with run-ahead 1 leaves the cabinet one + /// frame on and presents the screens of the frame after that. Until + /// v3.1.0 `produce_dual_frame` ignored both inputs. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_cabinet_rewinds_and_runs_ahead_through_the_produce_path() { + let rom = crate::runahead::tests::flashing_cabinet(); + let mut sinks = FrameSinks { + audio: None, + #[cfg(feature = "retroachievements")] + ra: None, + }; + let plain = quiet_inputs(); + let mut core = EmuCore::new(); + let mut cabinet = rustynes_core::VsDualSystem::from_rom(&rom).unwrap(); + cabinet.enable_rewind(); + core.set_dual(Box::new(cabinet)); + let presented = |core: &EmuCore| (core.present_fb.clone(), core.present_fb_sub.clone()); + let mut shown = Vec::new(); + for _ in 0..12 { + core.produce_one_frame(&plain, &mut sinks); + shown.push(presented(&core)); + } + assert_ne!(shown[10], shown[11], "the stimulus changes every frame"); + + let mut rewind = quiet_inputs(); + rewind.rewind_held = true; + // The newest entry is the frame on screen; each further step goes back one. + core.produce_one_frame(&rewind, &mut sinks); + assert_eq!(presented(&core), shown[11]); + core.produce_one_frame(&rewind, &mut sinks); + assert_eq!( + presented(&core), + shown[10], + "both screens of the frame before" + ); + core.produce_one_frame(&rewind, &mut sinks); + assert_eq!(presented(&core), shown[9]); + + let mut ahead = quiet_inputs(); + ahead.run_ahead = 1; + let before = core.dual.as_ref().unwrap().snapshot(); + core.produce_one_frame(&ahead, &mut sinks); + let mut probe = rustynes_core::VsDualSystem::from_rom(&rom).unwrap(); + probe.restore(&before).unwrap(); + probe.run_frame(); + assert_eq!( + core.dual.as_ref().unwrap().snapshot(), + probe.snapshot(), + "the cabinet itself advanced exactly one frame" + ); + probe.run_frame(); + assert_eq!( + presented(&core), + ( + probe.main_framebuffer().to_vec(), + probe.sub_framebuffer().to_vec() + ), + "the presented screens are the frame after" + ); + } + /// v2.9.7 (`T-PS-dual-savestate`) — a Vs. `DualSystem` cabinet saves and /// restores both consoles, byte for byte. A single-console blob and a /// cabinet blob are each refused by the other kind, and nothing restores diff --git a/crates/rustynes-frontend/src/runahead.rs b/crates/rustynes-frontend/src/runahead.rs index dfb55300f..a014e6c8c 100644 --- a/crates/rustynes-frontend/src/runahead.rs +++ b/crates/rustynes-frontend/src/runahead.rs @@ -29,7 +29,7 @@ //! so consecutive cycles produce the contiguous stream `N+1, N+2, …` — no //! gaps, no overlaps, just shifted by the same `N` frames as the video. -use rustynes_core::Nes; +use rustynes_core::{Nes, VsDualSystem}; /// Scratch state for the run-ahead cycle (reused buffers — no per-frame /// allocation in steady state). @@ -118,6 +118,57 @@ impl RunAhead { nes.set_rewind_capture(true); } + /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032 amendment of 2026-10-07) — + /// [`Self::run_frame_ahead`] for a Vs. `DualSystem` cabinet. The same + /// cycle on the whole cabinet: the persistent frame, a snapshot of BOTH + /// consoles and the latch wiring them (the `RVSD` container), `n - 1` + /// hidden frames, and the visible one. On return the cabinet holds the + /// visible frame's two framebuffers and the main console's un-drained + /// audio; the caller harvests them, then MUST call + /// [`Self::finish_cabinet`]. + /// + /// The unit is the cabinet and never one console: the two share a WRAM + /// and drive each other's `/IRQ`, so running one ahead alone would + /// present a future the partner never reached. + pub fn run_cabinet_ahead(&mut self, dual: &mut VsDualSystem, n: u32) { + debug_assert!(n >= 1); + // The persistent frame: the cabinet's timeline, captured by its + // rewind ring as a plain frame would be. + dual.run_frame(); + self.discard_cabinet_audio(dual); + dual.snapshot_into(&mut self.snap_buf); + // Hidden + visible frames are off-timeline: no rewind capture. + dual.set_rewind_capture(false); + for _ in 1..n { + dual.run_frame(); + self.discard_cabinet_audio(dual); + } + dual.run_frame(); + } + + /// Phase B of [`Self::run_cabinet_ahead`]: roll the cabinet back to the + /// persistent frame and re-enable its rewind capture. The rollback is + /// [`VsDualSystem::restore_quiet`], which keeps the cabinet's rewind + /// ring. The cabinet has no pixel- or audio-provenance stores to carry + /// around it: the debugger is scoped out of dual mode (ADR 0032). + /// + /// # Panics + /// + /// Panics if the snapshot fails to restore, which a container produced + /// by `snapshot_into` on the same cabinet cannot do. + pub fn finish_cabinet(&mut self, dual: &mut VsDualSystem) { + dual.restore_quiet(&self.snap_buf) + .expect("run-ahead cabinet snapshot round-trips on the same cabinet"); + dual.set_rewind_capture(true); + } + + /// Drain and discard both consoles' audio from a hidden cabinet frame. + fn discard_cabinet_audio(&mut self, dual: &mut VsDualSystem) { + let (main, sub) = dual.split_mut(); + self.discard_audio(main); + self.discard_audio(sub); + } + /// Drain and discard whatever audio the last frame synthesized. fn discard_audio(&mut self, nes: &mut Nes) { // Generously sized: one NTSC frame at 192 kHz is ~3200 samples. @@ -129,7 +180,7 @@ impl RunAhead { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use rustynes_core::Buttons; use std::path::PathBuf; @@ -217,6 +268,136 @@ mod tests { } } + /// v3.1.0 (`T-PS-dual-runahead`) — a synthetic Vs. `DualSystem` cart whose + /// two screens change colour every frame and differ from each other: each + /// console's NMI writes a frame counter into the backdrop entry `$3F00` + /// (eight colours, `$10-$17` on the main and `$20-$27` on the sub, none of + /// them black), with background rendering on over blank CHR-RAM. The same cart as `rustynes-test-harness`'s + /// `vs_dualsystem_rewind.rs`, which explains why the protocol cart there + /// (rendering off, one unchanging colour) is blind to a framebuffer + /// comparison. + pub fn flashing_cabinet() -> Vec { + #[rustfmt::skip] + let program = |base: u8| -> Vec { + vec![ + 0x78, 0xD8, 0xA2, 0xFF, 0x9A, // SEI CLD LDX #$FF TXS + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 1 + 0x2C, 0x02, 0x20, 0x10, 0xFB, // vblank 2 + 0xA9, 0x0A, 0x8D, 0x01, 0x20, // $2001 = $0A (background on) + 0xA9, 0x80, 0x8D, 0x00, 0x20, // $2000 = $80 (NMI on) + 0x4C, 0x19, 0x80, // JMP $8019 + // NMI at $801C + 0xE6, 0x00, 0xA5, 0x00, // INC $00, LDA $00 + 0x29, 0x07, 0x09, base, 0xEA, // AND #$07 ORA #base NOP + 0xA2, 0x3F, 0x8E, 0x06, 0x20, // $2006 = $3F + 0xA2, 0x00, 0x8E, 0x06, 0x20, // $2006 = $00 + 0x8D, 0x07, 0x20, // $3F00 = A + 0x8E, 0x06, 0x20, 0x8E, 0x06, 0x20, // v = $0000 + 0x40, // RTI (also the IRQ vector) + ] + }; + let mut rom = vec![0u8; 16 + 0x10000]; + rom[0..4].copy_from_slice(b"NES\x1a"); + rom[4] = 0x04; // 64 KiB PRG + rom[6] = 0x30; // mapper 99 + rom[7] = 0x69; // NES 2.0, Vs. System + rom[11] = 0x07; // 8 KiB CHR-RAM + rom[13] = 0x50; // Vs. hardware type 5: DualSystem + for (half, base) in [(0usize, 0x10u8), (0x8000, 0x20)] { + let code = program(base); + rom[16 + half..16 + half + code.len()].copy_from_slice(&code); + rom[16 + half + 0x7FFA..16 + half + 0x8000] + .copy_from_slice(&[0x1C, 0x80, 0x00, 0x80, 0x38, 0x80]); + } + rom + } + + fn cabinet() -> VsDualSystem { + match rustynes_core::Emu::from_rom(&flashing_cabinet()).expect("cart parses") { + rustynes_core::Emu::Dual(d) => *d, + rustynes_core::Emu::Single(_) => panic!("a DualSystem cart builds a cabinet"), + } + } + + /// v3.1.0 (`T-PS-dual-runahead`, ADR 0032's 2026-10-07 gate: "run-ahead + /// gives the same output as a run without it") — the cabinet form of + /// [`runahead_persistent_timeline_matches_plain_run`], at depths 1 and 2. + /// The persistent cabinet is byte-identical to a plain one after every + /// cycle, both screens of the visible frame are the plain run's frame `n` + /// later, and the rewind ring holds persistent frames only. + #[test] + fn cabinet_runahead_matches_a_plain_run_on_both_screens() { + for n in [1u32, 2] { + let mut ahead = cabinet(); + let mut plain = cabinet(); + ahead.enable_rewind(); + plain.enable_rewind(); + let mut ra = RunAhead::default(); + let mut discard = vec![0.0f32; 8192]; + let mut drain = |d: &mut VsDualSystem| { + let (main, sub) = d.split_mut(); + let _ = main.drain_audio_into(&mut discard); + let _ = sub.drain_audio_into(&mut discard); + }; + for f in 0..20u32 { + for d in [&mut ahead, &mut plain] { + d.set_buttons(0, buttons_for(f)); + d.run_frame(); + drain(d); + } + } + for f in 20..50u32 { + let input = buttons_for(f); + ahead.set_buttons(0, input); + ra.run_cabinet_ahead(&mut ahead, n); + let visible = ( + ahead.main_framebuffer().to_vec(), + ahead.sub_framebuffer().to_vec(), + ); + drain(&mut ahead); + ra.finish_cabinet(&mut ahead); + + plain.set_buttons(0, input); + plain.run_frame(); + drain(&mut plain); + assert_eq!( + ahead.snapshot(), + plain.snapshot(), + "n={n}: the persistent cabinet diverged from the plain one at {f}" + ); + + // The plain timeline `n` frames on, with the input held. + let mut probe = cabinet(); + probe.restore(&plain.snapshot()).expect("own snapshot"); + for _ in 0..n { + probe.set_buttons(0, input); + probe.run_frame(); + } + assert_eq!( + visible.0.as_slice(), + probe.main_framebuffer(), + "n={n}: the visible main screen at {f} is not the plain run {n} ahead" + ); + assert_eq!( + visible.1.as_slice(), + probe.sub_framebuffer(), + "n={n}: the visible sub screen at {f} is not the plain run {n} ahead" + ); + assert_ne!( + visible.0.as_slice(), + ahead.main_framebuffer(), + "the stimulus must tell the visible frame from the persistent one" + ); + } + assert_eq!( + ahead.rewind_len(), + plain.rewind_len(), + "n={n}: the ring holds the persistent frames and nothing else" + ); + assert!(ahead.rewind_capture_enabled(), "capture is back on"); + } + } + /// v1.0.0 (UX3 BUG-3) — Game Genie codes are a runtime PRG-read overlay /// that lives OUTSIDE the save-state, so the run-ahead snapshot/restore /// (`snapshot_core_into` + `restore_quiet`, run every visible frame) must diff --git a/crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs b/crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs new file mode 100644 index 000000000..75731c805 --- /dev/null +++ b/crates/rustynes-test-harness/tests/vs_dualsystem_rewind.rs @@ -0,0 +1,234 @@ +//! `T-PS-dual-runahead` (v3.1.0, FE-09, D27): rewind in Vs. `DualSystem` mode. +//! +//! ADR 0032's 2026-10-07 amendment lifts rewind and run-ahead into the +//! two-console cabinet, built on the `RVSD` container that already snapshots +//! both consoles and the latch wiring them. Its gate, pinned here for rewind +//! (run-ahead is pinned in `rustynes-frontend`, where it lives): +//! +//! * a rewind across a cabinet frame restores BOTH framebuffers +//! byte-identically; +//! * the cabinet then replays the same frames it produced the first time. +//! +//! The stimulus is a synthetic `DualSystem` cart built for the purpose. The +//! protocol cart in `vs_dualsystem_synth.rs` leaves the PPU off, so both of +//! its screens are one unchanging colour and a framebuffer comparison against +//! it would pass whatever a restore did to the pictures. This cart enables +//! background rendering and, in each console's NMI, writes a per-frame counter +//! into the backdrop entry `$3F00`. Its CHR-RAM is blank, so every pixel shows +//! the backdrop: each screen changes colour every frame, and the two screens +//! differ (the main cycles `$10-$17`, the sub `$20-$27`), so a restore that +//! swapped, dropped or staled either framebuffer is visible. The eight colours +//! avoid the palette's blacks (`$xD-$xF`): a full 64-entry counter walks +//! through three of them in a row, and consecutive frames are then identical. + +use rustynes_core::{Emu, VsDualSystem}; + +/// FNV-1a 64, as the other harness tests hash framebuffers. +fn fnv(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, &b| { + (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3) + }) +} + +/// One console's program: wait for the PPU to warm up, enable background +/// rendering and the NMI, then spin. The NMI increments `$00` and writes +/// `base | ($00 & 7)` into `$3F00`. +#[rustfmt::skip] +fn program(base: u8) -> Vec { + vec![ + /* 8000 */ 0x78, // SEI + /* 8001 */ 0xD8, // CLD + /* 8002 */ 0xA2, 0xFF, // LDX #$FF + /* 8004 */ 0x9A, // TXS + /* 8005 */ 0x2C, 0x02, 0x20, // BIT $2002 (first vblank) + /* 8008 */ 0x10, 0xFB, // BPL $8005 + /* 800A */ 0x2C, 0x02, 0x20, // BIT $2002 (second vblank) + /* 800D */ 0x10, 0xFB, // BPL $800A + /* 800F */ 0xA9, 0x0A, // LDA #$0A (background on) + /* 8011 */ 0x8D, 0x01, 0x20, // STA $2001 + /* 8014 */ 0xA9, 0x80, // LDA #$80 (NMI on) + /* 8016 */ 0x8D, 0x00, 0x20, // STA $2000 + /* 8019 */ 0x4C, 0x19, 0x80, // JMP $8019 + // NMI at $801C. + /* 801C */ 0xE6, 0x00, // INC $00 + /* 801E */ 0xA5, 0x00, // LDA $00 + /* 8020 */ 0x29, 0x07, // AND #$07 + /* 8022 */ 0x09, base, // ORA #base + /* 8024 */ 0xEA, // NOP (keeps the layout below) + /* 8025 */ 0xA2, 0x3F, // LDX #$3F + /* 8027 */ 0x8E, 0x06, 0x20, // STX $2006 + /* 802A */ 0xA2, 0x00, // LDX #$00 + /* 802C */ 0x8E, 0x06, 0x20, // STX $2006 + /* 802F */ 0x8D, 0x07, 0x20, // STA $2007 ($3F00 = the backdrop) + /* 8032 */ 0x8E, 0x06, 0x20, // STX $2006 (v back to $0000) + /* 8035 */ 0x8E, 0x06, 0x20, // STX $2006 + /* 8038 */ 0x40, // RTI (also the IRQ vector) + ] +} + +/// A NES 2.0 `DualSystem` cart (mapper 99, Vs. hardware type 5), 64 KiB PRG: +/// the main program in the first half, the sub program in the second, each +/// with its own vectors. CHR-RAM, left blank. +fn build_flashing_cabinet() -> Vec { + let mut rom = vec![0u8; 16 + 0x10000]; + rom[0..4].copy_from_slice(b"NES\x1a"); + rom[4] = 0x04; // 4 x 16 KiB PRG + rom[6] = 0x30; // mapper 99, low nibble + rom[7] = 0x69; // mapper 99 high nibble | NES 2.0 | Vs. System + rom[11] = 0x07; // 8 KiB CHR-RAM + rom[13] = 0x50; // Vs. hardware type 5 (DualSystem), PPU type 0 + let prg = &mut rom[16..]; + for (half, base) in [(0usize, 0x10u8), (0x8000, 0x20)] { + let code = program(base); + prg[half..half + code.len()].copy_from_slice(&code); + // NMI = $801C, RESET = $8000, IRQ = $8038 (an RTI). + prg[half + 0x7FFA..half + 0x8000].copy_from_slice(&[0x1C, 0x80, 0x00, 0x80, 0x38, 0x80]); + } + rom +} + +fn cabinet() -> VsDualSystem { + match Emu::from_rom(&build_flashing_cabinet()).expect("the synthetic cart parses") { + Emu::Dual(d) => *d, + Emu::Single(_) => panic!("Vs. hardware type 5 must build a cabinet"), + } +} + +/// Both screens' hashes, main first. +fn screens(dual: &VsDualSystem) -> (u64, u64) { + (fnv(dual.main_framebuffer()), fnv(dual.sub_framebuffer())) +} + +#[test] +fn the_stimulus_changes_both_screens_every_frame_and_they_differ() { + let mut dual = cabinet(); + for _ in 0..10 { + dual.run_frame(); + } + let mut seen = Vec::new(); + for _ in 0..4 { + dual.run_frame(); + let s = screens(&dual); + assert_ne!(s.0, s.1, "the two screens show different colours"); + seen.push(s); + } + for pair in seen.windows(2) { + assert_ne!(pair[0].0, pair[1].0, "the main screen changes every frame"); + assert_ne!(pair[0].1, pair[1].1, "the sub screen changes every frame"); + } +} + +#[test] +fn rewind_is_off_by_default_and_a_step_back_without_it_changes_nothing() { + let mut dual = cabinet(); + assert!(!dual.rewind_enabled()); + for _ in 0..5 { + dual.run_frame(); + } + let before = dual.snapshot(); + assert!(!dual.rewind_step_back(), "nothing to step back to"); + assert_eq!(dual.snapshot(), before, "the cabinet is untouched"); + assert_eq!(dual.rewind_len(), 0); +} + +#[test] +fn a_rewind_across_cabinet_frames_restores_both_framebuffers_exactly() { + let mut dual = cabinet(); + dual.enable_rewind_with(rustynes_core::REWIND_DEFAULT_MAX_BYTES, 4); + for _ in 0..10 { + dual.run_frame(); + } + // Record what frames 11..=20 looked like, and the whole state after each. + let mut history = Vec::new(); + for _ in 0..10 { + dual.run_frame(); + history.push((screens(&dual), dual.snapshot(), dual.main().frame())); + } + assert_eq!(dual.rewind_len(), 20, "one entry per frame"); + + // The first step back pops the newest entry: the frame on screen now. + assert!(dual.rewind_step_back()); + let (now_screens, now_state, _) = &history[9]; + assert_eq!(screens(&dual), *now_screens); + assert_eq!(dual.snapshot(), *now_state); + + // Each further step lands on the frame before, both screens and the + // whole cabinet byte for byte, across keyframes and deltas alike. + for back in (0..9).rev() { + assert!(dual.rewind_step_back(), "entry for history[{back}]"); + let (want_screens, want_state, want_frame) = &history[back]; + assert_eq!(dual.main().frame(), *want_frame); + assert_eq!( + screens(&dual), + *want_screens, + "both framebuffers after stepping back to history[{back}]" + ); + assert_eq!( + dual.snapshot(), + *want_state, + "the whole cabinet after stepping back to history[{back}]" + ); + } +} + +#[test] +fn play_resumed_after_a_rewind_replays_the_same_frames() { + let mut dual = cabinet(); + dual.enable_rewind_with(rustynes_core::REWIND_DEFAULT_MAX_BYTES, 4); + for _ in 0..12 { + dual.run_frame(); + } + let mut first = Vec::new(); + for _ in 0..6 { + dual.run_frame(); + first.push(screens(&dual)); + } + // Back to the frame before the six: the newest entry, then six more. + for _ in 0..7 { + assert!(dual.rewind_step_back()); + } + let mut second = Vec::new(); + for _ in 0..6 { + dual.run_frame(); + second.push(screens(&dual)); + } + assert_eq!( + first, second, + "the replay matches the original run, both screens" + ); +} + +#[test] +fn capture_off_frames_stay_out_of_the_ring() { + let mut dual = cabinet(); + dual.enable_rewind(); + dual.run_frame(); + dual.set_rewind_capture(false); + assert!(!dual.rewind_capture_enabled()); + dual.run_frame(); + dual.run_frame(); + dual.set_rewind_capture(true); + assert_eq!( + dual.rewind_len(), + 1, + "only the frame captured with capture on" + ); +} + +#[test] +fn a_loud_restore_and_a_power_cycle_empty_the_ring_and_a_quiet_one_keeps_it() { + let mut dual = cabinet(); + dual.enable_rewind(); + for _ in 0..3 { + dual.run_frame(); + } + let snap = dual.snapshot(); + dual.restore_quiet(&snap).expect("own snapshot"); + assert_eq!(dual.rewind_len(), 3, "a quiet restore keeps the ring"); + dual.restore(&snap).expect("own snapshot"); + assert_eq!(dual.rewind_len(), 0, "a loaded state replaces the timeline"); + dual.run_frame(); + dual.power_cycle(); + assert_eq!(dual.rewind_len(), 0, "a power cycle ends the timeline"); + assert!(dual.rewind_enabled(), "but rewind stays on"); +} diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index e3732ce1b..f9ac97e4e 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -39,7 +39,7 @@ disposition under the v2.1.0 "Fathom" accuracy-remediation line | Optional OAM decay (F2.3) | Dynamic-RAM sprite decay when rendering stays off: per-8-byte-row 3000-CPU-cycle refresh model (Mesen2 `ReadSpriteRam`/`WriteSpriteRam`), rows refreshed by sprite-eval + `$2004`/DMA, decayed rows read `((a&3)==2)?a&0xE3:a` | No decay test ROM (Mesen2 code-level parity); NTSC/Dendy-only | **Landed on `main`** (Unreleased, next tag; F2.3) — **default-off** so the golden vectors / AccuracyCoin / commercial oracle (all decay-off) stay byte-identical; a real core feature (affects the framebuffer when ON), deterministic off `dot_counter`, save-state v7 tail (relative-age) | | NTSC generated palette (F1.4) | Optional composite-model base palette synthesized in-core (`rustynes_ppu::generate_base_palette`) in place of the hand table | `full_palette` visual corpus + Mesen2/ares baselines (visual only) | **Shipped** (v2.1.2) — **default-off**, golden-locked cross-target; select via Settings → Palette → "Generated NTSC". Default build byte-identical | | NTSC composite shader ladder (F2.2) | Display-only GPU post-passes: simplified blur (`Ntsc`) → LMP88959 composite → Bisqwit per-dot (`CompositeRt`) | No pass/fail ROM (visual only); `visual_regression` stays byte-identical with any filter active | **Shipped** (v2.1.2) — three-rung ladder verified end-to-end, live emulator-synced dot-crawl now on both composite passes (`Lmp88959` phase wired), palette↔pass split documented; no separable-kernel rung (LMP covers that tier). See `docs/frontend.md` | -| Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — advanced features (run-ahead / rewind / netplay / TAS / dual save-state) scoped out in dual mode per ADR 0032; wasm/mobile deferred | +| Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — netplay / TAS scoped out in dual mode per ADR 0032; dual save-state since v2.9.7, rewind and run-ahead since v3.1.0 (the ADR's amendments); wasm/mobile deferred | | NSF non-60 Hz playback + NSFe | **Done** (F4.1/F4.2): the play-speed divider (`$6E-$6F`/`$78-$79`) is parsed and a non-standard rate (PAL 50 Hz / custom µs) drives `play` via a mapper cycle-timer IRQ (frame-IRQ-disabled); standard 60 Hz keeps the byte-identical vblank-NMI path. `NSFE` chunked container parsed (INFO/DATA/BANK/auth). | `nsf` unit + core integration tests | **Done** (F4.1/F4.2) | | FDS medium model (F4.3) | Byte-stream wire medium: gap / `$80` mark / block / **CRC-16-KERMIT** per block, with **per-block CRC re-emitted on write** (`resynth_block_crc`) and an opt-in **continuous belt-velocity head-seek** model (distance-proportional re-seek, default-off) replacing the fixed-cycle window | **CI-verifiable (synthetic):** `medium_write_verify` BIOS-free oracle — write via the register path, re-walk the wire, assert every block's CRC-16 + gap/mark framing round-trips (`fds::tests::synthetic_write_verify_*`). **Local-only:** the real-BIOS write-CRC path (BIOS recomputes CRC in its own RAM → `$4024`) needs a copyright `disksys.rom`, kept in gitignored `tests/roms/external/` and out of CI | **Shipped (v2.2.0 "Capstone")** — additive: default (model-off, non-writing) `.fds` run is **byte-identical**; new state round-trips the **v4** save-state tail. AccuracyCoin has no FDS ROM, so 141/141 is unaffected | | Famicom microphone ($4016.2) | Not modeled | Built-in controller-2 mic bit surfaced on `$4016` D2 (`Nes::set_microphone`); `famicom_microphone_drives_4016_bit2` bus unit test | **Shipped (v2.2.0)** — additive / default-off (mic released ⇒ `$4016` byte-identical); a `$4016`-only signal (never touches `$4017`). No pass/fail mic ROM exists (real-cart local test only) | diff --git a/docs/adr/0032-vs-dualsystem-desktop-presentation.md b/docs/adr/0032-vs-dualsystem-desktop-presentation.md index dbea0d5f0..74bf5afd6 100644 --- a/docs/adr/0032-vs-dualsystem-desktop-presentation.md +++ b/docs/adr/0032-vs-dualsystem-desktop-presentation.md @@ -78,3 +78,16 @@ The gate: Netplay and TAS (`T-PS-dual-netplay`), the debugger and HD packs stay out of dual mode. + +**Outcome (v3.1.0).** Implemented as decided. The cabinet owns a rewind ring +of whole RVSD containers, framebuffers included, so a step back restores both +screens without the re-render a single console's slim ring needs. +`VsDualSystem::restore_quiet` is the restore that keeps the ring, for +run-ahead's rollback and a rewind step; `restore` (a loaded state) and +`power_cycle` empty it. Both gate clauses are tests that mutation shows can +fail: `vs_dualsystem_rewind.rs` (a slim sub block in the capture, and a loud +restore in the step back, each caught) and `runahead.rs` +`cabinet_runahead_matches_a_plain_run_on_both_screens` at depths 1 and 2 (no +rollback, and capture left on across hidden frames, each caught). The stimulus +is a cart built for it: the protocol cart renders nothing, so a framebuffer +comparison against it is blind. diff --git a/docs/compatibility.md b/docs/compatibility.md index 6209de55b..e090c29f5 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -144,8 +144,9 @@ screens via `main_framebuffer()` and `sub_framebuffer()`. As of v2.1.2 "Fathom" (F2.1) the **desktop frontend presents both screens** — side-by-side (512×240, default) or stacked (256×480) via `[graphics] dual_screen_layout` — with P1/P2 → main, P3/P4 → sub, coin (F10) → main acceptor, and the main console's audio (ADR -0032). The advanced single-`Nes` features (run-ahead / rewind / netplay / TAS / -dual save-state), the debugger, and HD-pack are **scoped out in dual mode**; +0032). Netplay, TAS, the debugger, and HD-pack are **scoped out in dual mode**; +save states work there since v2.9.7, and rewind and run-ahead since v3.1.0, on +the whole cabinet; libretro + wasm + mobile presentation remain deferred; real-cabinet boot stays fixture-limited (maincpu-half dumps). The non-DualSystem games (Excitebike, Clu Clu Land, Castlevania, Pinball, Gradius, Goonies, Ice Climber, Golf, Super Mario diff --git a/docs/frontend.md b/docs/frontend.md index 066e7c2fc..8e749c7bb 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -774,10 +774,20 @@ both consoles. Every load path makes the same decision through command-line path installed a cabinet image as a single console, which runs the main CPU alone and never completes the boot handshake. The single-console path is byte-identical (the dual path is a parallel branch at each chokepoint). **Scoped out in dual mode (ADR 0032):** -run-ahead, rewind, netplay, TAS, the debugger, and HD-pack — they snapshot a -single `Nes`. **Save states work in dual mode since v2.9.7**, through the -cabinet's own "RVSD" snapshot: `EmuCore::save_state_blob` / -`restore_state_blob` (ADR 0032's amendment). Real-cabinet boot stays fixture-limited (the circulating +netplay, TAS, the debugger, and HD-pack. **Save states work in dual mode since +v2.9.7**, through the cabinet's own "RVSD" snapshot: `EmuCore::save_state_blob` +/ `restore_state_blob` (ADR 0032's first amendment). **Rewind and run-ahead +work in dual mode since v3.1.0** (`T-PS-dual-runahead`, the second amendment), +on the whole cabinet and never on one console, because the two share a WRAM +and drive each other's `/IRQ`. The cabinet has its own rewind ring +(`VsDualSystem::enable_rewind_with`, sized from `[rewind]` by `rewind_budget` +like a single console's), whose entries are whole RVSD containers with both +framebuffers, so a step back (`VsDualSystem::rewind_step_back`) restores both +screens exactly without re-rendering. Run-ahead is +`RunAhead::run_cabinet_ahead` / `finish_cabinet`: the single-console cycle on +the cabinet, rolled back with `VsDualSystem::restore_quiet`, which keeps the +ring. `produce_dual_frame` routes `rewind_held` and the run-ahead depth to +them; the cabinet still keeps stock timing (no overclock). Real-cabinet boot stays fixture-limited (the circulating dumps are the MAME maincpu half only). **Present-path parity (v2.1.10 "Web Parity").** The **libretro** core diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 93186d61d..300efa5fb 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1435,7 +1435,7 @@ Minted from the line plan, [`plans/v3.1-to-v4.0-line-plan.md`](plans/v3.1-to-v4.0-line-plan.md). Each row names the release slot that owns it and the backlog ID from the 2026-10-06 surveys. The release plans hold the gates. An existing ticket is reused, not -re-minted: `T-PS-dual-runahead` (ADR 0032), `T-MISTER-SAVESTATE`, +re-minted: `T-PS-dual-runahead` (ADR 0032; **DONE v3.1.0**), `T-MISTER-SAVESTATE`, `T-MISTER-CHEATS`, `T-MISTER-ZAPPER`, `T-MISTER-4PLAYER`, `T-MISTER-PADDLE`, `T-MISTER-KEYBOARD`, `T-MISTER-VMODE`, `T-MISTER-OSD` and `T-MISTER-DIRECTVIDEO` keep their sections above. diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index 91dfb27f4..c2bf509a4 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -96,3 +96,4 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | 6 | **Done.** `emit_pixel` forms the emphasis index as the physical tint, exchanging PPUMASK bits 5/6 off NTSC (NESdev "Colour emphasis"), for both framebuffers. NTSC unchanged; the sibling has no PAL | `pal_and_dendy_swap_the_red_and_green_emphasis_bits` red first (PAL gave index 97, red, for bit 5) | | 7 | **Done (scoped).** The inter-pixel colour artifacts were already reproduced by the raw signal-decode pass (v2.1.9); added the missing differential phase distortion there, as an opt-in knob (`diff_phase`, deg/row, default 0) in `signal_decode.wgsl`. Desktop only; mobile/web write 0; the Bisqwit CPU filter does not model it | `crt_stack_shaders_parse_and_validate` (naga, the wgpu front end) passes on the changed shader; the param test pins the knob and its 0 default. NOT shown on screen by an automated test: no GPU-output test exists for this pass, so the visual check is the maintainer's | | 8 | **Done.** `Mapper::set_mmc3_revision_override`, `Nes` / `HardwareOptions` (`mmc3_revision`), re-applied after a power-cycle rebuild; desktop `[emulation] mmc3_irq_revision`. Running `mmc3_test_2/6` under it FOUND a defect: the alternate revision did not assert on a `$C001` reload to 0 ("IRQ should be set when reloading due to clear"), which MMC3.md documents; fixed, Sharp unchanged | `mmc3_test_2_6_passes_under_the_alternate_revision_override` (and 5 then fails, proving the override acts); `the_mmc3_override_survives_a_power_cycle_and_clears`; all 20 MMC3 tests pass. Two older tests pinned the opposite (NEC silent on the reload) and were corrected with the wiki's text: `nec_asserts_once_on_a_c001_reload_to_zero_and_not_after`, and `mmc3_submapper_4_is_nec_and_0_is_sharp` now expects the single IRQ | +| 9 | **Done.** `VsDualSystem` owns a rewind ring of whole RVSD containers (framebuffers included), `restore_quiet` keeps it, `restore` and `power_cycle` empty it; `RunAhead::run_cabinet_ahead` / `finish_cabinet`; `produce_dual_frame` routes `rewind_held` and the run-ahead depth; the desktop sizes the ring from `[rewind]` (`rewind_budget`, one definition replacing three copies). ADR 0032's amendment gained an outcome note | `vs_dualsystem_rewind.rs` (6 tests, on a cart whose screens change every frame; mutants: a slim sub block in the capture, a loud restore in the step back, both caught); `cabinet_runahead_matches_a_plain_run_on_both_screens` at n = 1, 2 (mutants: no rollback, capture left on, both caught); `a_cabinet_rewinds_and_runs_ahead_through_the_produce_path` (mutants: rewind ignored, run-ahead ignored, both caught) | From aa7435b84bd62feb4e558ea6eed2b889a6fd8479 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 21:21:51 -0400 Subject: [PATCH 07/21] docs(mister): record the rewritten contribution page; re-scope the checklist v3.1.0 Phase S, SUB-1 and SUB-2 (plan item 16), plus a stale RTL-5 box. SUB-1. ref-docs/2026-10-07-mister-core-contribution-requirements-update.md records the MiSTer-devel page "Contributing a Core to MiSTer FPGA" as it stands, read from the wiki's own git repository (head 1227b217, 2026-10-06; the page's last revision 317b50e, 2026-09-26), so the text and the date of every edit are exact. The 2026-08-23 record (revision a6c9017) is immutable and stays; this one supersedes the parts it names. The page was rewritten, not edited. 6aaf88a (2026-09-19) removed the ten-step page and 01c1da7 (2026-09-20) added the current one; 2026-09-26 only reworded the update_all FAQ answer. That corrects the plan, which said the page "changed on 2026-09-26". What the rewrite removed matters to the submission case. Gone: "preservation value"; the sentence the case was built on ("Fully AI generated code should meet a minimum reasonable bar for readability and include some evidence of quality and accuracy testing"); the word "public"; the invitation, the repository transfer and the Cores-list step; "reviewed within a few days". Added: four reviewer criteria (the guidelines followed; does the developer understand their code; will they maintain it; do they collaborate) and an FAQ ("Is MiSTer-devel Anti-AI? Not at all ... as long as a developer understands their code"), a review time of "upwards of a month", and other routes into update_all, including a drop-in downloader database. The record lists the consequences for the maintainer to weigh and decides none of them: the testing evidence still answers "properly tested" but no longer a named criterion; the central question is now asked of the maintainer, which no test suite can answer; the duplicate-core risk is unstated rather than gone; and the private sibling repository must be reachable by a reviewer. SUB-2. to-dos/mister/contribution-checklist.md traces to both records and is re-scoped in place, keeping each box's history: the preservation-value box says the criterion is gone and why its evidence stays; the AI-code box becomes the reviewer's criteria; the transfer box asks about whatever next steps a review names; the Cores-list box is CONTINGENT on those steps. Two boxes are new: the repository being reachable by the reviewer (BLOCKED on a maintainer decision) and the drop-in database route (CONTINGENT). contribution_checklist_audit.rs: 14 passed, including the three protected boxes and the completion sentence; markdownlint clean. RTL-5 (plan item 13) turned out to be closed already: sibling mkrom program 75, ppudecay075, has caught all three decay mutations since v2.9.5 (docs/rung3-ppu.md there). The TASKS.md box stayed open for four releases after its work landed, which is how v3.1.0 came to plan it again; it is ticked with the citation. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- ...r-core-contribution-requirements-update.md | 125 ++++++++++++++++++ to-dos/mister/IMPLEMENTATION_PLAN.md | 2 +- to-dos/mister/TASKS.md | 9 +- to-dos/mister/contribution-checklist.md | 62 +++++++-- 4 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 ref-docs/2026-10-07-mister-core-contribution-requirements-update.md diff --git a/ref-docs/2026-10-07-mister-core-contribution-requirements-update.md b/ref-docs/2026-10-07-mister-core-contribution-requirements-update.md new file mode 100644 index 000000000..61cf189c9 --- /dev/null +++ b/ref-docs/2026-10-07-mister-core-contribution-requirements-update.md @@ -0,0 +1,125 @@ +# MiSTer core contribution page: what changed after 2026-08-23 + +**Dated supplemental reference, 2026-10-07.** It supersedes the parts of +[`2026-08-23-mister-core-contribution-requirements.md`](2026-08-23-mister-core-contribution-requirements.md) +named below. That file is immutable and stays as written; this one records +what the page says now. (v3.1.0, SUB-1.) + +**Primary source:** the MiSTer-devel wiki page *Contributing a Core to MiSTer +FPGA* +(), +read on 2026-10-07 from the wiki's own git repository +(`https://github.com/MiSTer-devel/Wiki_MiSTer.wiki.git`, head `1227b217`, +2026-10-06). The page's latest revision is `317b50e` (2026-09-26). Reading the +repository rather than the rendered page gives the exact text and the date of +every edit. Passages below are quoted from it. + +## The revision history since the 2026-08-23 record + +The 2026-08-23 record was taken from revision `a6c9017` (2026-08-20). Six +revisions followed: + +| revision | date | size of the change | what it did | +| --- | --- | --- | --- | +| `6aaf88a` | 2026-09-19 | 61 lines removed | "Update guidelines": the ten-step page removed | +| `01c1da7` | 2026-09-20 | 84 lines added | the page as it now stands: guidelines, reviewer criteria, FAQ | +| `e634b2e` | 2026-09-20 | 1 line | wording | +| `242277d` | 2026-09-21 | 2 lines | wording | +| `1dfe906` | 2026-09-21 | 1 line | wording | +| `317b50e` | 2026-09-26 | 7 added, 4 removed | the `update_all` FAQ answer | + +**Correction to the plan's wording.** `to-dos/mister/IMPLEMENTATION_PLAN.md` +says the page "changed on 2026-09-26". The material rewrite is +`6aaf88a` + `01c1da7`, on 2026-09-19 and 2026-09-20. The 2026-09-26 revision +edits only the FAQ answer about `update_all`: MiSTer-devel goes from "among the +most restrictive paths" to "just one of the different paths", Coin-Op +Collection is added as a contact, and the drop-in database moves into its own +paragraph. + +## What the page now asks + +The page is scoped to one repository: "This document is to help folks get +their cores ready for submission to MiSTer-devel specifically. Other repos may +have other guidelines." + +**Core Submission Guidelines** (numbered on the page): + +1. "Your code needs to be published under a compatible open source license + like **GPLv3** or **MIT**". +2. The template repository structure: `sys/`, `rtl/`, `releases/`, and the + standard root files (`.qpf`, `.qsf`, `.srf`, `.sdc`, `.sv`, `files.qip`, + `clean.bat`, `.gitignore`). Unchanged. +3. "Your code should not rely on custom edits to framework files in the `sys` + folder. Repo admins should be able to maintain your core by dropping in a + fresh `sys` folder and building an updated binary." +4. The MiSTer development principles and coding guidelines. Unchanged. +5. At least one fully functional compiled release in `releases/` as + `_YYYYMMDD.rbf`. Unchanged. +6. Arcade cores only: MRA files. Unchanged, and not applicable. + +**Submission:** "Once your repo is ready, and your code is properly tested, +send a link to your repo to newcores@misterfpga.org. A MiSTer-devel member will +reach out once they have a chance to review the repo." + +**What Reviewers Are Looking For** (new). The page calls these "much more +nebulous than the guidelines above": + +- "Does the code follow the guidelines above?" A missed guideline goes on a + list of fixes; "It's the refusal to follow the guidelines that will + disqualify you." +- "Does the developer understand their code?" The page is explicit that + prompting an LLM to build a core is fine ("_There is nothing wrong with + this_"), and equally explicit that "if you're submitting to MiSTer-devel, + you should understand your code as it is core to the next bullet point." +- "Is the developer willing to maintain their code?" Fixing bugs, responding + to issues, adding features. Reviewers "will be less inclined to believe that + this is something you're interested in doing if you are too busy developing + your next core." +- "Does the developer collaborate well with others?" + +**FAQ** (new): a review "can take upwards of a month in some situations"; +MiSTer-devel is one route into `update_all` among several (Jotego, Coin-Op +Collection, theypsilon, or a drop-in downloader database); and "Is MiSTer-devel +Anti-AI? Not at all ... as long as a developer understands their code, how +they manifested that code is of little consequence." + +## What was removed, against the 2026-08-23 record + +| 2026-08-23 record | now | +| --- | --- | +| §1 "The core must demonstrate **preservation value** through accurate implementation of the original system" | **gone**. No accuracy or preservation criterion appears on the page | +| §2 "Fully AI generated code should meet a minimum reasonable bar for readability and include some evidence of quality and accuracy testing" | **gone**, replaced by "Does the developer understand their code?" | +| step 2 "Publish the project as a public GitHub repository" | the word "public" is gone; the reviewer still needs "a link to your repo" | +| §6 steps 3-5: accept the organisation invitation, **transfer the repository**, add it to the Cores list with a unique Home folder | **gone** from the page. What follows a review is "an email back with the decision and next steps" | +| "Your request will be reviewed within a few days" | "upwards of a month in some situations" | + +## What this means for RustyNES + +These are consequences for the submission case, recorded for the maintainer to +weigh. None of them is a decision. + +- **The case was built on a sentence that is no longer on the page.** The + 2026-08-23 record called the AI-code sentence "the single most important + sentence on the page for this project", because the co-simulation evidence + answers it directly. The evidence is unchanged and still worth presenting: + the guidelines ask for code that is "properly tested". It no longer answers + a stated criterion by name. +- **The new central question is about the maintainer, not the code.** + "Does the developer understand their code?" and "Is the developer willing + to maintain their code?" are asked of a person, and no test suite can + answer them. The sibling's long RTL comments (D14), its per-rung documents + and its oracle-vs-documentation ledger are evidence a reviewer can use to + check the answer, but they cannot give it. +- **The duplicate-core risk is now unstated rather than gone.** The page no + longer mentions preservation value or redundancy. `NES_MiSTer` still + exists. Whether reviewers weigh that is not written anywhere. +- **The repository must be reachable by the reviewer.** `RustyNES_MiSTer` is + private today, and the page asks for a link to the repository. Making it + public is the maintainer's decision and the last step before the email. +- **The repository transfer is no longer described.** Whatever "next steps" + follow a positive review, the page no longer says the repository moves. + The 2026-08-23 record's warning about that one-way action now refers to a + step the page does not describe. +- **Unchanged and already met:** the licence (GPL-3.0-or-later), the template + layout, an unmodified `sys/` (the sibling vendors it verbatim, checked by + `tb/check_sys.py`), and the release naming (`RustyNES_YYYYMMDD.rbf`). diff --git a/to-dos/mister/IMPLEMENTATION_PLAN.md b/to-dos/mister/IMPLEMENTATION_PLAN.md index 67204181a..da2dd41fd 100644 --- a/to-dos/mister/IMPLEMENTATION_PLAN.md +++ b/to-dos/mister/IMPLEMENTATION_PLAN.md @@ -31,7 +31,7 @@ complete core; the phase table below is in the new order. The narrative for the | Phase | Release slot | Content | Decisions | |---|---|---|---| -| **S** (submission prep, docs only) | v3.1.0, then kept current through to H | SUB-1 (a dated `ref-docs/` record of the live contribution page; it changed on 2026-09-26), SUB-2 (re-scope the checklist), SUB-5 (refresh `submission-case.md`); TL-5 (this file, done) | D10, D14 (the RTL keeps its long comments) | +| **S** (submission prep, docs only) | v3.1.0, then kept current through to H | SUB-1 (a dated `ref-docs/` record of the live contribution page; rewritten 2026-09-19/20, last edited 2026-09-26; **done v3.1.0**, `ref-docs/2026-10-07-mister-core-contribution-requirements-update.md`), SUB-2 (re-scope the checklist), SUB-5 (refresh `submission-case.md`); TL-5 (this file, done) | D10, D14 (the RTL keeps its long comments) | | **F1** (cheap breadth) | v3.2.0 | FB-16 options first (custom palette, +8 sprites), FB-2 SUROM/SXROM, the 206 family, 66, 11, 79, 9/10, 118/119, 71/232, 34, the trivial discretes, FB-10 paddle, FB-8 Four Score, FB-6 cheats | D12, D26 | | **F2** (the memory platform) | v3.3.0 | FB-20 arbiter (RTL-9 closes), DDR3, FB-4 save states, FB-5 rewind, the real `hps_io` under Verilator; **the off-die build becomes the headline** and on-die a "lite" build | D4, D12, D16 | | **F3** (big boards, audio) | v3.4.0-v3.5.0 | MMC2/4, FME-7/5B, VRC2/4, the Zapper (v3.4.0); MMC5, N163, VRC6, VRC7, Bandai FCG with expansion audio, Famicom peripherals (v3.5.0) | D15 | diff --git a/to-dos/mister/TASKS.md b/to-dos/mister/TASKS.md index 77d7faffa..31307a2ae 100644 --- a/to-dos/mister/TASKS.md +++ b/to-dos/mister/TASKS.md @@ -181,10 +181,15 @@ Legend: `[ ]` open · `[~]` in progress · `[x]` done incompatible by ~17x**, with no independent oracle to adjudicate: risk 6 arriving as a measurement. The constant stays the oracle's, labelled fitted, in a `localparam` - - [ ] A purpose-built decay stimulus, to close the three mutations that are + - [x] A purpose-built decay stimulus, to close the three mutations that are inert **on this ROM** (which timer gates which bits, and where the deadline sits inside a 1.27 s gap). Named as a step, not a silence — and - note it would confirm this core matches 600 ms, not what hardware does + note it would confirm this core matches 600 ms, not what hardware does. + **Done at v2.9.5, ticked at v3.1.0 (RTL-5):** `ppudecay075` (mkrom + program 75) catches all three mutations, 22,829 / 22,829 / 71,428 + cycles (sibling `docs/rung3-ppu.md`, "v2.9.5: the purpose-built ROM + exists"). The box stayed open for four releases after its work landed, + which is why v3.1.0 planned it again - [x] **`rtl/nes_top.sv` assembles the console** and carries **not one observation port** — everything the gates read is reached hierarchically from the co-simulation wrapper, which is legal in diff --git a/to-dos/mister/contribution-checklist.md b/to-dos/mister/contribution-checklist.md index 2abe8f25f..6eb60b707 100644 --- a/to-dos/mister/contribution-checklist.md +++ b/to-dos/mister/contribution-checklist.md @@ -2,7 +2,18 @@ Every line traces to `ref-docs/2026-08-23-mister-core-contribution-requirements.md`, which quotes the -MiSTer-devel wiki fetched 2026-08-23. **Nothing here is from memory.** +MiSTer-devel wiki fetched 2026-08-23, **and, from v3.1.0, to +`ref-docs/2026-10-07-mister-core-contribution-requirements-update.md`**, which +records the page as rewritten on 2026-09-19 and 2026-09-20 (revision `317b50e`, +2026-09-26, is the live one). **Nothing here is from memory.** + +**Re-scoped at v3.1.0 (SUB-2).** The rewrite dropped "preservation value", the +AI-code sentence ("evidence of quality and accuracy testing"), the repository +transfer, and the Cores-list step, and added four reviewer criteria: the +guidelines followed, the developer understands the code, will maintain it, and +collaborates. Boxes that traced to a removed requirement are kept with their +history and re-scoped in place rather than deleted, so the record of why each +one was there survives. The whole list must be complete **by the hardware-verification release (v3.x)**, which is the submission ([ADR 0041](../../docs/adr/0041-hardware-release-is-v3.0.0.md) moved it from v2.7.0 to v3.0.0 on 2026-09-22; [ADR 0043](../../docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md) moved it after v3.0.0 on 2026-09-29, when v3.0.0 became the API major with an unverified release-candidate core); it is NOT complete now, and an item left unchecked below is carried deliberately rather than overlooked. Individual items are marked with the release that settled them -- **(now)** for ones true @@ -201,7 +212,10 @@ Settled at **v2.6.6**, except the one item that needs a board. ## Quality bar - [x] Core is accurate enough to demonstrate **preservation value** **(v2.6.14)** - — measured rather than asserted, and by two independent surfaces. The + — **no longer a stated requirement (v3.1.0):** the 2026-09-20 rewrite + removed the criterion. The evidence below is kept because the page still + asks for code that is "properly tested", and this is the testing. Measured + rather than asserted, and by two independent surfaces. The AccuracyCoin status vector is identical to the oracle's **entry for entry across all 146 entries**, with 146 of 146 executed on both sides and none `NotRun` (v2.6.5). Six commercial titles render **byte-identically over @@ -220,9 +234,16 @@ Settled at **v2.6.6**, except the one item that needs a board. attached to this machine, confirmed by checking the USB bus, serial devices, removable block devices and mounts rather than assumed. Nothing in this repository can close it. **Unblocks on hardware.** -- [ ] **AI-generated-code bar:** readability, plus *"evidence of quality and - accuracy testing"* — the co-simulation record is that evidence, and the - submission should link it explicitly rather than assume a reviewer finds it +- [ ] **The reviewer's criteria: properly tested, and a developer who + understands the code, will maintain it and collaborates.** Re-scoped at + v3.1.0 from the **AI-generated-code bar** (*"evidence of quality and + accuracy testing"*), which the 2026-09-20 rewrite replaced with "Does the + developer understand their code?" and its two companions. The testing + half is what the co-simulation record answers, as before. The other three + are asked of the maintainer, and no document in this repository can + answer them; the long RTL comments (D14), the per-rung documents and the + oracle-vs-documentation ledger are what a reviewer can use to check the + answer. **BLOCKED — on the submission itself.** **The evidence is now also ARGUED rather than merely linkable (v2.6.15):** `RustyNES_MiSTer/docs/submission-case.md` is the document the email will @@ -279,6 +300,13 @@ Settled at **v2.6.6**, except the one item that needs a board. ## Submission +- [ ] The repository is reachable by the reviewer: the page asks for "a link + to your repo", and `RustyNES_MiSTer` is private + **BLOCKED — on a maintainer decision** (v3.1.0). The 2026-09-20 rewrite + dropped the word "public" from the guidelines, but a reviewer still has to + open the link. Making the repository public, or granting the reviewer + access, is the maintainer's call and the last step before the email. + Every item here is **BLOCKED — the submission IS the hardware-verification release (v3.x)** (ADR 0043; it was v3.0.0 under ADR 0041), by the programme's own definition, and three of the four are somebody else's action rather than this @@ -288,15 +316,21 @@ outstanding work. - [ ] Email `newcores@misterfpga.org` with the repository link **BLOCKED — the hardware-verification release (v3.x).** Sending it before the quality bar closes is the whole thing the checklist exists to prevent. -- [ ] Await review (the page says days) +- [ ] Await review (the page said days; since 2026-09-20 it says "upwards of a + month in some situations") **BLOCKED — not ours to do**, and it follows the email. -- [ ] **Decide deliberately** on the MiSTer-devel invitation and repository - transfer — acceptance moves the repo, it is one-way, and this project owns it +- [ ] **Decide deliberately** on what a positive review asks for — until + 2026-09-19 the page said an invitation to MiSTer-devel and a repository + transfer, a one-way move of a repository this project owns **BLOCKED — on being accepted**, and then it is a maintainer decision - rather than a task. Named here so acceptance does not arrive as a - surprise with a one-way consequence attached. + rather than a task. The rewrite no longer describes the transfer; a + review ends in "an email back with the decision and next steps". Kept so + whatever those steps are does not arrive as a surprise with a one-way + consequence attached. - [ ] Add to the Cores list with the Home folder - **BLOCKED — on acceptance.** The Home folder itself is already settled: + **CONTINGENT — on the next steps a positive review names.** The step is + gone from the page since 2026-09-19 (v3.1.0), so it may no longer be the + submitter's to do. The Home folder itself is already settled: `CONF_STR`'s first field gives `/media/fat/games/RustyNES`, and it is unique — the incumbent core's internal name is `NES` (v2.6.7). @@ -314,6 +348,12 @@ Not a failure path — a planned one. See **CONTINGENT — on being declined.** The design decision that keeps it cheap is already taken and holds today: `nes_top.sv` carries no MiSTer framework dependency, and `emu.sv` is the only file that does. +- [ ] A drop-in downloader database (`downloader_.ini`, from the + `DB-Template_MiSTer` template), the route the page itself offers outside + MiSTer-devel, alongside asking Jotego, Coin-Op Collection or theypsilon + **CONTINGENT — on being declined, or on choosing it instead** (v3.1.0). + It reaches `update` and `update_all` users with no review at all, so it + is also open to the maintainer before or without a submission. - [ ] The co-simulation evidence is publishable on its own terms regardless **DECIDED — this is a statement, not a task**, and it is unconditional: the ladder, its goldens and its mutation records stand whatever any From 638d5d34676cdc31979dbe1ffb509d4b1f31a532 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 21:43:08 -0400 Subject: [PATCH 08/21] docs: v3.1.0 records -- DOC-01..09, the v1.8.x checklist fold, a masked 100% v3.1.0 plan item 10. Each stale statement from the 2026-10-06 backlog survey was re-checked against the code before it was changed, and the survey's DOC table, which existed only in a session transcript, is now in to-dos/plans/v3.1.0-plan.md with each item's outcome. What the checking changed about the survey's own claims: - DOC-02's "open questions (answered, FE-11)" was half right. The CRC32 question is half-answered: rustynes-gamedb, vendored from TetaNES, IS CRC32-keyed, while the Vs. database and save naming key on SHA-256. (A first draft of this change said "SHA-256 only"; reading the crate refuted it.) The region override is genuinely open: no frontend has one. - DOC-03 found an error the survey did not name: mappers.md called the Sharp default "MMC3A", which is the other revision. - DOC-04: `vrc24test` really is absent from the corpus; VRC2/4 coverage is m22's CHR walk plus local dumps. Kept, made precise. - DOC-05: CI-05 is obsolete (no x86_64-apple-darwin since v1.6.0, ADR 0009); the other two ROADMAP items were already done on 2026-10-07. - DOC-06: libretro-super#2131 merged 2026-10-06 (9c08e5e6f3, read with gh); docs#1215 is open. - DOC-07: already correct after the v3.0.1 cut; verified, not edited. - DOC-08: the wasm.rs comment's version claim for the winit build was not written, because `wasm-winit` predates v1.3.0 in the tree; the comment says what exists rather than when. Decision D25: to-dos/v1.8.x-on-device-verification.md is folded into docs/mobile-v2.9.3-run-sheet.md as rows G1-G17 and deleted. Only rows the run sheet did not already cover were kept (A1, A3-A7, A10 and A13 cover the rest). One row was dropped because it had become wrong: "the picker offers iNES / NES 2.0 only", when FDS and NSF shipped in v2.9.7 (T1-T7). Its three live links (STATUS, the v2.0.x mobile plan) now point at the run sheet; CHANGELOG-FULL and salvaged copies are history and keep theirs. The CHANGELOG now says plainly what the AccuracyCoin re-sync revealed: every release that reported 144/144 (or 141/141) failed parts of `Misaligned OAM behavior` as the fixed ROM scores it, because the old ROM's fail path returned into the test and recorded a pass. v3.1.0's 146/146 is the first without that masked failure. The S6 release-notes gate in the plan now requires the same statement in the notes. Verification: markdownlint on every touched file; the eleven release audits (contribution_checklist, cosim_manifest, f2_accuracy, feature_flag, libretro_info, libretro_makefile, mister_source_map, provenance_record, release_anchor, release_notes_render, release_state_prose): all pass. The code edits are doc comments only. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 5 + crates/rustynes-android/src/gfx.rs | 6 +- crates/rustynes-frontend/src/netplay_ui.rs | 13 ++- crates/rustynes-frontend/src/ntsc.rs | 4 +- crates/rustynes-frontend/src/wasm.rs | 9 +- docs/STATUS.md | 2 +- docs/compatibility.md | 38 ++++--- docs/ios-v1.9.9-readiness.md | 6 ++ docs/libretro/UPSTREAM_SYNC.md | 13 ++- docs/mappers.md | 20 +++- docs/mobile-v2.9.3-run-sheet.md | 35 ++++++ docs/nesdev-hardware-emulation-checklist.md | 20 ++-- to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md | 15 +++ to-dos/ROADMAP.md | 6 +- .../plans/v2.0.x-mobile-finalization-plan.md | 6 +- to-dos/plans/v3.1.0-plan.md | 20 +++- to-dos/v1.8.x-on-device-verification.md | 101 ------------------ 17 files changed, 173 insertions(+), 146 deletions(-) delete mode 100644 to-dos/v1.8.x-on-device-verification.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1668593eb..0e40da2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ cycle-accurate core later replaced. in-range X (it always realigned). The old ROM recorded these failures as a pass: its fail path returned into the test without popping the return address, fixed upstream in `adacbc23`. + - **So the earlier 100% scores were overstated.** Every release that + reported AccuracyCoin 144/144 (and 141/141 before it) failed parts of + `Misaligned OAM behavior` as the fixed ROM scores it; the old ROM's bug + recorded those failures as a pass. v3.1.0 is the first release whose 100% does not + include that masked failure. - **PAL and Dendy games that use colour emphasis show the right tint.** On the PAL 2C07 and the Dendy, PPUMASK bits 5 and 6 swap meaning (green and red); every PAL or Dendy game that set emphasis was tinted the wrong way. diff --git a/crates/rustynes-android/src/gfx.rs b/crates/rustynes-android/src/gfx.rs index 01cbd17ec..273676017 100644 --- a/crates/rustynes-android/src/gfx.rs +++ b/crates/rustynes-android/src/gfx.rs @@ -14,8 +14,10 @@ //! frontend (the `rustynes-gfx-shaders` crate), so the on-screen filter look matches //! across platforms: the `params` uniform selects None / Scanlines / CRT on one //! pipeline (None = a plain letterboxed blit), and a second pipeline runs the -//! LMP88959 NTSC pass when `filter == 3`. Only the Bisqwit NTSC pass (which needs the -//! `R16Uint` palette-index texture from the bridge) remains a follow-up. +//! LMP88959 NTSC pass when `filter == 3`. A third pipeline runs the Bisqwit composite +//! NTSC pass when `filter == 4`, reading the `R16Uint` palette-index texture the +//! bridge supplies (`index_texture`, `bisqwit_pipeline`). This line called that pass +//! a follow-up until v3.1.0, long after it landed. use ndk::native_window::NativeWindow; use raw_window_handle::{AndroidDisplayHandle, HasWindowHandle, RawDisplayHandle}; diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index fad69423e..b7915797d 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -238,11 +238,14 @@ impl NetplayUi { /// /// `num_players` (2..=4) selects how many players the session runs; 3-4 /// players enable the Four Score adapter. It is clamped into `2..=4`. The - /// multi-joiner UDP handshake (a host adopting several joiners + assigning - /// each a player index) is a follow-up — the N-player rollback core + - /// determinism proof live in `rustynes-netplay`; the native UDP layer currently - /// completes the first joiner's handshake. The selected `num_players` is - /// still recorded so the session + Four Score wiring is in place. + /// multi-joiner UDP handshake (a host adopting several joiners and + /// assigning each a player index) exists in `rustynes_netplay::mesh_net` + /// since v2.6.0 (`MeshHost`, `UdpMeshTransport`), with the N-player + /// rollback core and its determinism proof; this desktop path does not use + /// it yet and completes the first joiner's handshake only. Wiring it is the + /// v3.1 → v4.0 line plan's "native 3-4 players" (v3.4.0). The selected + /// `num_players` is still recorded so the session + Four Score wiring is in + /// place. /// /// The host no longer needs to pre-enter the joiner's address — it just /// shares its own listening `IP:port` and the joiner dials in (see diff --git a/crates/rustynes-frontend/src/ntsc.rs b/crates/rustynes-frontend/src/ntsc.rs index b9fea1f6a..cc0ec5113 100644 --- a/crates/rustynes-frontend/src/ntsc.rs +++ b/crates/rustynes-frontend/src/ntsc.rs @@ -14,7 +14,9 @@ //! coarse Blargg trick). //! //! Not a bit-exact port of `nes_ntsc`. Marked `ntsc-simple` in the config -//! to set expectations. A full NES_NTSC port is a v1.1 follow-up. +//! to set expectations. The signal-accurate filters are the other rungs of +//! the ladder this one starts: the LMP88959 decode and the Bisqwit per-dot +//! composite (`CompositeRt`), both in the shared shader stack since v2.1.2. //! //! Performance: 5 texture taps per surface pixel; on a 768x720 window //! that's ~2.8M taps/frame, well below GPU memory-bandwidth limits. diff --git a/crates/rustynes-frontend/src/wasm.rs b/crates/rustynes-frontend/src/wasm.rs index c5ccaeab2..191440068 100644 --- a/crates/rustynes-frontend/src/wasm.rs +++ b/crates/rustynes-frontend/src/wasm.rs @@ -15,9 +15,12 @@ //! path: the PPU framebuffer is already RGBA8 256x240, which is //! byte-identical to the canvas `ImageData` format, so a direct //! `put_image_data` blit gets a WORKING browser emulator NOW. The -//! winit/wgpu unification (so the egui debugger overlay + NTSC -//! filter work on web too) is a follow-up sprint (1.4). Audio + -//! `IndexedDB` save state are also follow-ups. +//! winit/wgpu unification, so the egui shell and the NTSC filter work +//! on the web too, is now the default `wasm-winit` build; this module +//! is the lightweight `wasm-canvas` embed beside it. Audio +//! (`crate::wasm_audio`, Sprint 1.4c) and `IndexedDB` save states +//! (v1.4.0 E2) reach this path too. (Until v3.1.0 this paragraph still +//! called all three follow-ups.) //! //! See `docs/audit/v1.3-sprint-1.3-wasm-canvas-mvp-2026-05-24.md`. diff --git a/docs/STATUS.md b/docs/STATUS.md index fd650d4fb..2d345ac96 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1097,7 +1097,7 @@ > `installDebug`→`installFossDebug` alias. Ad / RevenueCat glue stayed dormant and was > later removed entirely (ADR 0035 — RustyNES is permanently open-source and > income-free); on-device dual-flavor verification and F-Droid submission remain a -> forward step. See `docs/android.md` + `to-dos/v1.8.x-on-device-verification.md`. android.yml CI (the +> forward step. See `docs/android.md` + `docs/mobile-v2.9.3-run-sheet.md` (the v1.8.x checklist folded into it at v3.1.0). android.yml CI (the > NDK cross-build + both-flavor Gradle package) is the compile gate for this change. > > **The preceding release: v2.0.0 "Timebase"** (2026-07-03) — the **one-clock, diff --git a/docs/compatibility.md b/docs/compatibility.md index e090c29f5..67c9794f6 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -146,9 +146,11 @@ default) or stacked (256×480) via `[graphics] dual_screen_layout` — with P1/P main, P3/P4 → sub, coin (F10) → main acceptor, and the main console's audio (ADR 0032). Netplay, TAS, the debugger, and HD-pack are **scoped out in dual mode**; save states work there since v2.9.7, and rewind and run-ahead since v3.1.0, on -the whole cabinet; -libretro + wasm + mobile presentation remain deferred; real-cabinet boot stays -fixture-limited (maincpu-half dumps). The non-DualSystem games (Excitebike, Clu +the whole cabinet. Libretro presents both screens since v2.1.10 (512×240, +`docs/libretro/advanced_features.md`), the browser since v2.9.7, and the mobile +bridge carries the cabinet since v2.9.7 (its device rows are T1-T12 of +`docs/mobile-v2.9.3-run-sheet.md`; the Swift half has not been compiled). +Real-cabinet boot stays fixture-limited (maincpu-half dumps). The non-DualSystem games (Excitebike, Clu Clu Land, Castlevania, Pinball, Gradius, Goonies, Ice Climber, Golf, Super Mario Bros.) boot and render with their correct 2C04 palette. **PlayChoice-10's second-screen instruction menu and its Z80 coprocessor are out @@ -212,7 +214,7 @@ unfinished); the fix is a faithful port of TriCNES's eval-pointer model — the corrupted index is the live secondary-OAM evaluation pointer (`OAM2Address`), captured at the disable edge during dots 1-64 and committed on re-enable. Default builds stay byte-identical for games without such a split; AccuracyCoin -OAM-Corruption (0x047B) + 139/141 (the two newest upstream PPU tests are known gaps), nestest 0-diff, and blargg/kevtris remain +OAM-Corruption (0x047B) + 139/141 at the time (the two newest upstream PPU tests were then known gaps; 146/146 at upstream `f5f41dc2` since v3.1.0, `docs/STATUS.md`), nestest 0-diff, and blargg/kevtris remain green; `repro_smb3 --movie` idle drop count went 63-80/240 → 0. See `ppu-2c02.md` edge-case 2 and `crates/rustynes-test-harness/src/bin/{repro_smb3,smb3_dma_trace}.rs`. @@ -221,10 +223,10 @@ edge-case 2 and `crates/rustynes-test-harness/src/bin/{repro_smb3,smb3_dma_trace | Mapper audio | Status | Notes | |--------------|--------|-------| | MMC5 (2 pulse + raw PCM) | **Landed** (`mapper-audio`, Track C2 / Phase 2.3) | Castlevania III JP, Just Breed, Laser Invasion | -| VRC6 (3 channels) | Phase 4 | Akumajou Densetsu, Madara, Esper Dream 2 | +| VRC6 (3 channels) | **Landed** (`m024_vrc6.rs`, `Mapper::mix_audio`) | Akumajou Densetsu, Madara, Esper Dream 2 | | VRC7 (FM, 6 channels) | **Landed** — clean-room `emu2413` port (`crates/rustynes-apu/src/opll.rs`, MIT); ADR 0006 supersedes ADR 0004 | Lagrange Point (JP) plays with in-game audio. | -| Sunsoft 5B (3 channels) | Phase 4 | Gimmick! | -| Namco 163 (1-8 channels) | Phase 4 | Several Japanese RPGs | +| Sunsoft 5B (3 channels) | **Landed** (`m069_sunsoft_fme7.rs`, `Mapper::mix_audio`) | Gimmick! | +| Namco 163 (1-8 channels) | **Landed** (`m019_namco163.rs`, `Mapper::mix_audio`) | Several Japanese RPGs | | FDS (wavetable + envelope) | **Landed** | 2C33 — 64-entry wavetable + 32-step modulation + envelopes + master volume; behind `mapper-audio` | ## Game compatibility goals (v1.0) @@ -321,8 +323,9 @@ This section supersedes the early "Out-of-scope" list above where they disagree punching bag (`Nes::set_bandai_hyper_shot`, the 8-sensor `$4016`-bit-1- multiplexed read, unit-verified against the `NESdev` "Exciting Boxing Punching Bag" page). All are additive, default-off `InputDevice` overlays, so - `ExpansionDevice::None` keeps every read byte-identical.) The microphone - remains deferred. (DMC-DMA controller-bit corruption is **modelled** as of + `ExpansionDevice::None` keeps every read byte-identical.) The Famicom + microphone shipped in v2.2.0 (`Nes::set_microphone`, `$4016` D2; + `docs/accuracy-ledger.md`). (DMC-DMA controller-bit corruption is **modelled** as of v1.4.0 — see `Bus::dmc_dma_read`, gated by `dmc_dma_during_read4/dma_4016_read`, `sprdma_and_dmc_dma`, and `read_joy3/count_errors`.) - **Vs. System / PlayChoice-10 (2C03/04/05 RGB PPUs) — game-verified.** @@ -340,11 +343,22 @@ This section supersedes the early "Out-of-scope" list above where they disagree accepted only when: (a) there is concrete user demand or a notable title that needs it, **and** (b) a redistributable test fixture or a well-specified nesdev page exists, **and** (c) it carries NES 2.0 metadata for unambiguous - detection — weighed against maintenance cost. MMC5's >8 KiB multi-chip PRG-RAM - configs fall under this policy (no corpus fixture; out of scope until one - appears). + detection — weighed against maintenance cost. (MMC5's >8 KiB multi-chip + PRG-RAM configs were named here as falling under this policy; v2.7.2 + implemented them from `MMC5.xhtml` as the wiki's 64 KiB superset, + `docs/audits/core-disposition.md` §5.3.) ## Open questions +Checked at v3.1.0 against the code, and recorded together as backlog item +FE-11, which the v3.1 → v4.0 line plan does not schedule: + +- the **CRC32 question is half-answered**: the per-game correction database + (`crates/rustynes-gamedb`, vendored from TetaNES) is CRC32-keyed, while the + Vs. System database (`crates/rustynes-core/src/vs_db.rs`) and save-state + naming key on SHA-256; +- the **region override is still open**: no desktop, mobile or libretro + frontend overrides the region the header declares. + - **CRC32 vs. SHA-256 for ROM identification.** We use SHA-256 for save state directory naming (lower collision risk). For ROM compatibility databases, CRC32 is the community standard; we may add it as a secondary key. - **Region override.** Some users want to play PAL versions of NTSC games at NTSC speed. Plan: expose a region override in the settings UI; warn that this may break timing-sensitive ROMs. diff --git a/docs/ios-v1.9.9-readiness.md b/docs/ios-v1.9.9-readiness.md index bf71e1eb5..c3d107835 100644 --- a/docs/ios-v1.9.9-readiness.md +++ b/docs/ios-v1.9.9-readiness.md @@ -104,6 +104,12 @@ advertised `.fds` / `.nsf`, which would fail on selection. v1.9.9 trims the pick document types to NES (+ `.zip`) for honesty; FDS + NSF on mobile are a post-v2.0.0 carryover. +> **Later (pointer added v3.1.0, records item DOC-09; this record is historical +> and is otherwise left as written).** The carryover shipped in **v2.9.7**: the +> mobile bridge loads FDS (with a host-supplied BIOS), NSF and the Vs. +> DualSystem cabinet. The Swift half has not been compiled, and the device rows +> are T1-T12 of [`mobile-v2.9.3-run-sheet.md`](mobile-v2.9.3-run-sheet.md). + ## 4. Completeness-critic findings No merge-blocking defects. Polish items found and dispositioned: diff --git a/docs/libretro/UPSTREAM_SYNC.md b/docs/libretro/UPSTREAM_SYNC.md index cf1b3efe8..27c492326 100644 --- a/docs/libretro/UPSTREAM_SYNC.md +++ b/docs/libretro/UPSTREAM_SYNC.md @@ -155,11 +155,18 @@ At this point, you can safely navigate to your repository settings on GitHub and --- -## Sync for v3.0.0 (prepared 2026-10-04, not submitted) +## Sync for v3.0.0 (prepared 2026-10-04, submitted 2026-10-06) Both forks were synced to upstream `master` (`libretro-super` `a7054054af`, -`docs` `36e9222824`) and carry a branch `rustynes-v3.0.0-sync`. No pull request -is open; v3.0.0 submits them (ADR 0043). +`docs` `36e9222824`) and carry a branch `rustynes-v3.0.0-sync`. + +**Status (checked 2026-10-07, v3.1.0 records item DOC-06).** Both were +submitted after the v3.0.0 tag. The `.info` PR, +[libretro/libretro-super#2131](https://github.com/libretro/libretro-super/pull/2131), +**merged** on 2026-10-06 (`9c08e5e6f3`). The docs page PR, +[libretro/docs#1215](https://github.com/libretro/docs/pull/1215), is **open**, +with its one review finding fixed and answered. Until v3.1.0 this section said +no pull request was open. | repo | branch commit | change | | --- | --- | --- | diff --git a/docs/mappers.md b/docs/mappers.md index 1e771310f..15eea5301 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -1024,7 +1024,21 @@ chunked `NSFE` containers; the FDS-style `$5FF6/$5FF7` RAM banking remains defer ## Open questions -- **MMC3 default revision** when iNES (no submapper) is detected. Plan: default Sharp (MMC3A) since Star Trek requires it; expose a config override. -- **Mapper #5 (MMC5) audio scope.** Implementing the 2 extra pulse + raw PCM channels is non-trivial; defer behind a `mmc5-audio` cargo feature. +Re-checked against the code at v3.1.0 (records item DOC-03): the first two +and the fourth are answered, and are kept with their answers rather than +deleted. + +- **MMC3 default revision** when iNES (no submapper) is detected. *Answered:* + the default is Sharp (the "normal" IRQ behaviour); NES 2.0 submapper 4 selects + the alternate one (MMC3A and the non-Sharp MMC3B), and since v3.1.0 any + mapper-4 game can be forced either way (`Mapper::set_mmc3_revision_override`, + desktop `[emulation] mmc3_irq_revision`, `T-MMC3-NEC-OVERRIDE`). This line + used to call the Sharp default "MMC3A", which is the other revision. +- **Mapper #5 (MMC5) audio scope.** *Answered:* the two extra pulses and the + raw PCM channel landed behind `mapper-audio` (Track C2 / Phase 2.3; the audio + table in `docs/compatibility.md`). - **VRC7 FM audio.** YM2413-derived; only Lagrange Point uses it commercially. Banking + IRQ landed in Track C2 / Phase 2.4 (mapper 85; same `mapper-audio` feature flag as VRC6 / Sunsoft 5B / Namco 163 / MMC5). **The FM synthesizer landed** via a clean-room pure-Rust port of `emu2413 v1.5.9` (MIT) at `crates/rustynes-apu/src/opll.rs`; ADR 0006 (`docs/adr/0006-vrc7-audio-landed.md`) supersedes the ADR 0004 deferral. *Lagrange Point* plays with in-game audio (mixed via the `mapper-audio` slot). -- **Pirate / multicart mappers.** 60+ exist; none in initial scope. Architecture supports adding them but no commitment. +- **Pirate / multicart mappers.** *Answered by policy:* none were in the initial + scope; many are in now (191 families, `docs/STATUS.md`), each admitted under + the long-tail policy in `docs/compatibility.md` (demand, a fixture or a + specific NESdev page, and NES 2.0 detection). diff --git a/docs/mobile-v2.9.3-run-sheet.md b/docs/mobile-v2.9.3-run-sheet.md index 893a4bb8e..3d7ef9c01 100644 --- a/docs/mobile-v2.9.3-run-sheet.md +++ b/docs/mobile-v2.9.3-run-sheet.md @@ -162,6 +162,41 @@ identity, returns a pre-v2.9.9 entry for the same file instead of adding one) an platform: cloud save-state records (Play Games snapshots, CloudKit), which the next upload writes under the new key. +## Rows folded in from the v1.8.x Android checklist (v3.1.0, decision D25) + +`to-dos/v1.8.x-on-device-verification.md` was the standing Android checklist +from v1.8.x to the v2.1.0 launch plan. v3.1.0 folds it in here and deletes it, +so one document carries the device runs. Only its rows that no row above +already covers are kept, rewritten for the current tree; its history is in git. +Dropped as covered: battery SRAM across a restart (A1), audio focus, PiP and +background (A3-A6), rotate and PiP return (A7), save-state slots (A10) and +multi-touch (A13). One row was dropped because it is now **wrong**: "the +picker offers iNES / NES 2.0 only". FDS and NSF shipped in v2.9.7 (T1-T7). Its +accuracy paragraph ("AccuracyCoin 139/141") is replaced by the rule it stated: +the device runs the host's byte-identical core, so AccuracyCoin (146/146 at +v3.1.0) is measured on the host and never on a phone, and a device that +diverges from the host's frames has a shell bug, not a core one. + +| # | Platform | Step | Expect | Result | +| --- | --- | --- | --- | --- | +| G1 | Android | Import a `.nes` through the Storage Access Framework picker | It appears in the library and boots | NOT RUN | +| G2 | Android | Import a NES 2.0 ROM; open its ROM info | Mapper and region read correctly | NOT RUN | +| G3 | Android | Online, open the library | Box art auto-matches a grid entry (skip on `foss` offline) | NOT RUN | +| G4 | Android | Re-open a previously imported ROM from the library | It resumes cleanly | NOT RUN | +| G5 | Android | Hold rewind, then release | The picture runs backward smoothly, then forward | NOT RUN | +| G6 | Android | Load a `.rns` written by the desktop build of the SAME release, and write one back | Each loads on the other side | NOT RUN | +| G7 | Android | Load a `.rns` or `.rnm` from v3.0.1 or earlier | Refused with a clean message naming the reason (state layout, or emulation epoch for a movie), never a crash | NOT RUN | +| G8 | Android | Pair a hardware controller (Xbox, DualSense or MFi, Bluetooth or USB-OTG) | Binds to P1: South=A, West=B, Start, Select, D-pad | NOT RUN | +| G9 | Android | Pair two to four controllers | Each binds to its own port (the Four Score path) | NOT RUN | +| G10 | Android | Disconnect and reconnect a controller mid-game | No crash and no stuck input | NOT RUN | +| G11 | Android | Plug and unplug headphones while playing | Audio keeps playing; the audio thread does not wedge | NOT RUN | +| G12 | Android | *Super Mario Bros.*: World 1-1 through the first screen | Status bar, sprites and scroll correct, no flicker | NOT RUN | +| G13 | Android | *The Legend of Zelda*: title, overworld, then an in-game save and reload | Renders correctly; the save round-trips | NOT RUN | +| G14 | Android | From one save state, play the same inputs twice | Identical results (the determinism contract on the device) | NOT RUN | +| G15 | Android | On a tablet or unfolded foldable, then a phone | Two-pane and compact layouts both correct; the image letterboxes at any aspect | NOT RUN | +| G16 | Android | Fold and unfold mid-game; on Android TV, navigate with the D-pad | No restart on fold; every TV control reachable, and the app boots to its TV banner | NOT RUN | +| G17 | Android | `foss` flavor: inspect the merged manifest and Settings | No `AD_ID` permission, no Play Services metadata, no Billing, Play Games or Cast surface (ADR 0025) | NOT RUN | + ## Android EMULATOR is what the `Pixel_8_API_34` emulator showed on this build, before diff --git a/docs/nesdev-hardware-emulation-checklist.md b/docs/nesdev-hardware-emulation-checklist.md index f2d5f757e..7fe8afc9e 100644 --- a/docs/nesdev-hardware-emulation-checklist.md +++ b/docs/nesdev-hardware-emulation-checklist.md @@ -65,7 +65,7 @@ Primary source clusters: | Reset sequence | Suppress reset stack writes but decrement S by 3 and fetch $FFFC/$FFFD | Implemented; cold-boot SP regression tests exist | | Power-up register state | A/X/Y normally 0 on tested hardware; RAM is unreliable; reset preserves most state | Deterministic test mode (default) + seeded randomized developer mode (`Nes::from_rom_with_power_on_seed`) both landed v1.5.0 (T-72-002) | | Unofficial opcodes | Implement all NMOS 6502 unofficial opcodes including unstable store family | Implemented; **SH\* pass 6/6 in AccuracyCoin under R1** — the deeper internal/external data-bus split is coarse but no test demands more | -| Interrupt polling | NMI edge-sensitive, IRQ level-sensitive, BRK/IRQ/NMI vector hijacking | **Closed under the R1 master clock:** `cpu_interrupts_v2` 5/5 strict; the only residual is `mmc3_test_2/4` #3 (ADR-0002 axis, deferred) | +| Interrupt polling | NMI edge-sensitive, IRQ level-sensitive, BRK/IRQ/NMI vector hijacking | **Closed under the R1 master clock:** `cpu_interrupts_v2` 5/5 strict. The last residual, `mmc3_test_2/4` #3, closed in v3.0.0 (`T-MMC3-BG-A12`; `mmc3_test_2_4_scanline_timing_strict`) | | Dummy reads/writes | Preserve all documented dummy bus accesses and RMW double writes | Implemented for major suites; internal-vs-external bus modeling remains a v1.x TODO | | DMA halt eligibility | DMA can halt only on CPU read cycles | Implemented; continue to guard with DMC/OAM DMA tests | @@ -79,7 +79,7 @@ Primary source clusters: | Loopy v/t/x/w | Keep scroll latch behavior and render-time horizontal/vertical reload timing | Implemented; guarded by PPU tests and visual baselines | | Background fetch pipeline | Preserve all visible, prefetch, and extra nametable fetches | Implemented; Mesen2 trace tooling documents exact pipeline | | Sprite evaluation | Per-dot secondary-OAM clear, copy, overflow bug, and OAMADDR walk | Implemented; the sub-cycle flag-timing residuals **closed under R1** (AccuracyCoin sprite-eval 9/9) | -| Sprite 0 hit | Set during rendering with left-edge, dot, and pre-render restrictions | Implemented; residual stale-shifter cases tracked | +| Sprite 0 hit | Set during rendering with left-edge, dot, and pre-render restrictions | Implemented; the stale-shifter case is still open, as ACC-04 (v3.3.0 in the v3.1 → v4.0 line plan) | | NTSC odd-frame skip | Skip dot on odd frames only when rendering is enabled | Implemented and tested by `ppu_vbl_nmi` | | Region variants | PAL and Dendy timing must not be inferred from NTSC constants | **Region-exact under the R1 master clock:** 3:1 NTSC/Dendy, hardware-true **3.2:1 PAL**; `region_timing` 4/4 | | PPU variants | 2C03/2C04/2C05/Vs. palettes and behavior are separate from stock 2C02 | Out of scope — a separate platform initiative (Vs. System / PlayChoice-10); load-time diagnostics only | @@ -89,11 +89,11 @@ Primary source clusters: | Behavior | Required emulator treatment | Project status | |---|---|---| | Frame counter write delay | $4017 effects occur after 3 or 4 CPU clocks depending on APU-cycle phase | Implemented for test ROMs; keep in APU docs | -| 4-step IRQ | Frame IRQ is set only in 4-step mode when IRQ inhibit is clear; $4015 read clears old flag | Implemented with residual AccuracyCoin frame-IRQ cases | +| 4-step IRQ | Frame IRQ is set only in 4-step mode when IRQ inhibit is clear; $4015 read clears old flag | Implemented; no AccuracyCoin residual (146/146 at upstream `f5f41dc2`, v3.1.0) | | 5-step mode | No frame IRQ; mode write can clock quarter/half frame units | Implemented | | Nonlinear mixer | Use nonlinear pulse and TND paths before console filtering | Implemented | -| DMC load vs reload DMA | Model different scheduling phase, dummy cycle, and alignment cycle | Implemented; residual $4015/$4016 bracket cases remain | -| DMA register-read bugs | Repeated halted reads must affect $2007/$4015/$4016/$4017 side-effect registers | Implemented enough for blargg; AccuracyCoin residuals remain | +| DMC load vs reload DMA | Model different scheduling phase, dummy cycle, and alignment cycle | Implemented; no AccuracyCoin residual (146/146, v3.1.0) | +| DMA register-read bugs | Repeated halted reads must affect $2007/$4015/$4016/$4017 side-effect registers | Implemented; blargg's DMA suites pass and AccuracyCoin has no residual (146/146, v3.1.0) | | Controller strobe/read | $4016 low 3 bits latch OUT lines; $4016/$4017 reads clock device and return D0-D4 plus open bus | Standard pads + **Four Score** + **Arkanoid Vaus paddle + Zapper** via the opt-in per-port `InputDevice` overlay; microphone / other expansion devices remain deferred | | DMC controller conflict | Reads can lose or duplicate joypad bits during DMC DMA | **Resolved (v1.4.0).** Modelled in `Bus::dmc_dma_read` (the `$4016`/`$4017` DMC-fetch conflict clocks the controller shift register, ORing open-bus high bits with the controller read; PAL / non-`$4000`-page guarded). Verified by the strict blargg `dmc_dma_during_read4/dma_4016_read`, `sprdma_and_dmc_dma` (+`_512`), and the `read_joy3/count_errors`/`count_errors_fast` conflict-stress smokes (`count_errors` renders "Conflicts: 149/1000", all compensated) — all green | @@ -106,7 +106,7 @@ Primary source clusters: | Nametable layout naming | Prefer explicit CIRAM A10 behavior over ambiguous "horizontal/vertical mirroring" prose | Documented in `docs/mappers.md` and `docs/cartridge-format.md` | | Bus conflicts | For discrete boards, mapper sees CPU value AND PRG ROM byte at the written address | Implemented for supported conflict mappers | | MMC1 | Serial 5-write protocol, reset write, and consecutive-cycle write ignore behavior | Implemented | -| MMC3 | Filtered PPU A12 rising edge after sufficient low time; revision-sensitive IRQ behavior | Implemented with one scanline-timing residual | +| MMC3 | Filtered PPU A12 rising edge after sufficient low time; revision-sensitive IRQ behavior | Implemented; the scanline-timing residual closed in v3.0.0, and both revisions are selectable since v3.1.0 (`T-MMC3-NEC-OVERRIDE`) | | MMC5 | PRG/CHR modes, ExRAM, fill, split, multiplier, scanline IRQ, audio | Feature-complete incl. audio (v1.x); only the >8 KiB multi-chip PRG-RAM configs remain (long-tail policy, no fixture) | | Expansion audio | VRC6, Sunsoft 5B, Namco 163, MMC5, VRC7, FDS require mapper/APU integration | **All landed** via `mix_audio`→`tick_with_external`: VRC6 / 5B / N163 / MMC5 / VRC7 OPLL FM / **FDS 2C33 wavetable** | | FDS | BIOS, disk timing, IRQs, writable media, and FDS audio are a separate platform surface | **Supported:** parser + RAM adaptor (mapper 20) + user-supplied `disksys.rom` BIOS + read/write drive + timer/transfer IRQs + writable `.fds.sav` + 2C33 wavetable audio. Real-BIOS boot unverified in CI (BIOS non-distributable); device + audio unit-tested | @@ -117,11 +117,11 @@ Primary source clusters: |---|---|---| | CPU reset/power | `cpu_reset`, power-on unit tests, randomized RAM developer mode | Add missing external ROM fixture if license permits | | CPU instruction edges | `instr_test_v5`, `instr_misc`, `cpu_timing_test6`, dummy reads/writes | `instr_misc` (5) + `instr_timing` (2) + `cpu_reset` vendored + wired v1.5.0 (T-71-002/003) | -| CPU interrupts | `cpu_interrupts_v2`, IRQ trace fixture, mapper IRQ tests | C1 residual carried forward | +| CPU interrupts | `cpu_interrupts_v2`, IRQ trace fixture, mapper IRQ tests | Passing; the C1 residual (`mmc3_test_2/4` #3) closed in v3.0.0 | | PPU vblank/open bus | `ppu_vbl_nmi`, `ppu_open_bus`, PPU state traces | Passing; keep traces for future changes | -| Sprites/OAM | sprite hit/overflow, `oam_read`, `oam_stress`, AccuracyCoin | Residuals tracked under Cascade A | -| APU/DMA | `apu_test`, `apu_mixer`, `dmc_dma_during_read4`, `sprdma_and_dmc_dma`, `read_joy3`, `pal_apu_tests`, `240pee` (240p suite) | Passing; AccuracyCoin residuals remain. v1.4.0 added the `240pee` UxROM+BNROM render gates and confirmed the DMC-controller-conflict coverage | -| Mapper timing | `mmc3_test_2`, `mmc3_irq_tests`, Holy Mapperel, mapper-specific ROMs | MMC3 sub-test #3 residual; VRC24 test source unresolved | +| Sprites/OAM | sprite hit/overflow, `oam_read`, `oam_stress`, AccuracyCoin | Passing except the sprite-0 stale-shifter case (ACC-04, v3.3.0) | +| APU/DMA | `apu_test`, `apu_mixer`, `dmc_dma_during_read4`, `sprdma_and_dmc_dma`, `read_joy3`, `pal_apu_tests`, `240pee` (240p suite) | Passing; no AccuracyCoin residual (146/146, v3.1.0). v1.4.0 added the `240pee` UxROM+BNROM render gates and confirmed the DMC-controller-conflict coverage | +| Mapper timing | `mmc3_test_2`, `mmc3_irq_tests`, Holy Mapperel, mapper-specific ROMs | MMC3: all of `mmc3_test_2` strict (v3.0.0). VRC2/4: AWJ's `vrc24test` is still not in the corpus; coverage is `m22`'s CHR-bank walk (`tests/m22.rs`) and the local dumps in `tests/roms/external/` | | Input | Standard pads, DMC conflict, Four Score/Zapper/expansion devices | Standard pads only; expanded devices are v1.x | | Commercial canaries | User-supplied external snapshots, never committed ROM bytes | 60-ROM oracle exists | diff --git a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md index 55a94df67..d39646ee7 100644 --- a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md +++ b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md @@ -431,6 +431,13 @@ variant is what would put the first two in front of a user at all.)* > sprite-0 stale-shifter item are still open in §6b; R2, R4, R5, the `$2002` > race, the `$2007` read and PAL alignment are closed below with evidence. In > §6c the CPU-multiplier overclock is the one open build item.)* +> +> *(2026-10-07, v3.1.0 records item DOC-01: **R1 shipped** in v3.0.0 as +> `T-MMC3-BG-A12` (§6b's `[x]` below), and **the CPU-multiplier overclock +> shipped** in v3.1.0 as `T-CPU-OVERCLOCK` (`Nes::set_cpu_overclock`). The +> sprite-0 stale shifter is the one item left in §6b; the line plan schedules +> it as ACC-04 in **v3.3.0**, with ACC-03. AccuracyCoin is 146/146 at upstream +> `f5f41dc2` (v3.1.0; `docs/STATUS.md`).)* All remaining hard-tier accuracy residuals share **one root cause** and converge on the v2.0.0 one-clock + every-cycle-bus-access refactor. They are **outside the @@ -1065,6 +1072,14 @@ plan); the A/V, HD-audio, shader/NTSC, GPU-timing and egui-render verifies → the original re-bless is done (§7, commit `c286e632`) and each future broken-boot fix re-blesses its own snapshots, which next applies in **v2.9.6**.)* +*(2026-10-07, DOC-01: F1 and the other device runs belong to the hardware +release, which D29 placed at the end of v3.9.x; F3 and the browser-RA deploy +are **v3.4.0** (hosting, D19); the A/V, HD-audio, shader/NTSC, GPU-timing and +egui-render verifies stay **unscheduled**. The "next applies in v2.9.6" above +is history: v2.9.6 shipped, and the re-bless rule is now enforced by the epoch +fingerprint gate (`T-EPOCH-FINGERPRINT`, v3.1.0), which fails a moved output +until the epoch rises.)* + --- ## 11. CI / tooling follow-ups (proposed, not yet implemented) diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 300efa5fb..19af3c4d7 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -504,7 +504,11 @@ deferred to a v1.4.x follow-up (UI compiles + no-ops on wasm). See ### Release engineering (v1.x) -- [→] **CI: `macos-15-intel` runner sunset — August 2027.** GitHub will +- [x] **CI: `macos-15-intel` runner sunset — August 2027.** *(OBSOLETE, closed + at v3.1.0, records item DOC-05 / CI-05: nothing uses the label. The + `x86_64-apple-darwin` release target was dropped in v1.6.0 (ADR 0009; the + note at `.github/workflows/release.yml:72`), and v3.0.1 moved the remaining + macOS jobs to `macos-15`. The entry is kept as written below.)* GitHub will decommission the `macos-15-intel` label after that date (per `actions/runner-images#13045`). Plan: migrate to `cargo-zigbuild` cross-compile from Linux, or drop `x86_64-apple-darwin` from the diff --git a/to-dos/plans/v2.0.x-mobile-finalization-plan.md b/to-dos/plans/v2.0.x-mobile-finalization-plan.md index f1864d6c1..d6fd771de 100644 --- a/to-dos/plans/v2.0.x-mobile-finalization-plan.md +++ b/to-dos/plans/v2.0.x-mobile-finalization-plan.md @@ -60,7 +60,7 @@ Nothing freezes. Between now and v2.0.0: - **Android (v1.8.x):** keep cutting **GitHub-sideload** releases. Finish the **v1.8.9** batch (the side-pane / foldable UX fix + the held docs) and run the **batched on-device verification pass** (all v1.8.6–v1.8.8 items, see - [`../v1.8.x-on-device-verification.md`](../v1.8.x-on-device-verification.md)). + [`../../docs/mobile-v2.9.3-run-sheet.md`](../../docs/mobile-v2.9.3-run-sheet.md) (the v1.8.x checklist, folded in at v3.1.0)). The PGS / Play-Integrity / Chromecast features stay **default-off behind their build flags** (`PGS_ENABLED`, `PLAY_INTEGRITY_ENABLED`, `CHROMECAST_ENABLED`) — they are flipped on only at **v2.1.0**, not now. @@ -212,7 +212,7 @@ rationale + mechanics in **ADR 0025**. Work items (sequence within v2.0.1–v2.0 the only distribution channels until then. - The **batched on-device verification pass is a hard gate** on the v2.1.0 production launch (it already gated the deferred Play launch; it now gates the - joint launch). See [`../v1.8.x-on-device-verification.md`](../v1.8.x-on-device-verification.md). + joint launch). See [`../../docs/mobile-v2.9.3-run-sheet.md`](../../docs/mobile-v2.9.3-run-sheet.md) (the v1.8.x checklist, folded in at v3.1.0). ## Cross-references updated for this plan @@ -221,6 +221,6 @@ rationale + mechanics in **ADR 0025**. Work items (sequence within v2.0.1–v2.0 - [`v1.8.0-android-plan.md`](v1.8.0-android-plan.md) / [`v1.9.0-ios-plan.md`](v1.9.0-ios-plan.md) — launch-half superseded banners - [`v1.9.x-ios-train-plan.md`](v1.9.x-ios-train-plan.md) — the full v1.9.0–v1.9.9 iOS TestFlight train (finalized here in v2.0.5–v2.0.8) - [`v2.0.0-master-clock-plan.md`](v2.0.0-master-clock-plan.md) — "what follows v2.0.0" -- [`../v1.8.x-on-device-verification.md`](../v1.8.x-on-device-verification.md) — gating sequence +- [`../../docs/mobile-v2.9.3-run-sheet.md`](../../docs/mobile-v2.9.3-run-sheet.md) (the v1.8.x checklist, folded in at v3.1.0) — gating sequence - [`../DEFERRED-AND-CARRYOVER-FEATURES.md`](../DEFERRED-AND-CARRYOVER-FEATURES.md) — the launch deferral - `CLAUDE.md`, `docs/STATUS.md`, `docs/android.md` — milestone sequence diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index c2bf509a4..cd03f1469 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -69,7 +69,7 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | 3.1-S3 | Items 6, 7 and 8: emphasis, composite artifacts, the NEC override | items 6-8 | — | | 3.1-S4 | Item 9: dual-mode rewind and run-ahead | item 9 | — | | 3.1-S5 | Items 12-16: the sibling, in parallel with S2-S4 (it touches no oracle file) | items 12-16 | — | -| 3.1-S6 | Items 10 and 11, the ecosystem watch, the release cut | full gates; ladders; release notes listing every format break (D2) | — | +| 3.1-S6 | Items 10 and 11, the ecosystem watch, the release cut | full gates; ladders; release notes listing every format break (D2), and stating plainly that every earlier AccuracyCoin 100% included the `Misaligned OAM behavior` failure the old ROM masked | — | ## Risks @@ -97,3 +97,21 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | 7 | **Done (scoped).** The inter-pixel colour artifacts were already reproduced by the raw signal-decode pass (v2.1.9); added the missing differential phase distortion there, as an opt-in knob (`diff_phase`, deg/row, default 0) in `signal_decode.wgsl`. Desktop only; mobile/web write 0; the Bisqwit CPU filter does not model it | `crt_stack_shaders_parse_and_validate` (naga, the wgpu front end) passes on the changed shader; the param test pins the knob and its 0 default. NOT shown on screen by an automated test: no GPU-output test exists for this pass, so the visual check is the maintainer's | | 8 | **Done.** `Mapper::set_mmc3_revision_override`, `Nes` / `HardwareOptions` (`mmc3_revision`), re-applied after a power-cycle rebuild; desktop `[emulation] mmc3_irq_revision`. Running `mmc3_test_2/6` under it FOUND a defect: the alternate revision did not assert on a `$C001` reload to 0 ("IRQ should be set when reloading due to clear"), which MMC3.md documents; fixed, Sharp unchanged | `mmc3_test_2_6_passes_under_the_alternate_revision_override` (and 5 then fails, proving the override acts); `the_mmc3_override_survives_a_power_cycle_and_clears`; all 20 MMC3 tests pass. Two older tests pinned the opposite (NEC silent on the reload) and were corrected with the wiki's text: `nec_asserts_once_on_a_c001_reload_to_zero_and_not_after`, and `mmc3_submapper_4_is_nec_and_0_is_sharp` now expects the single IRQ | | 9 | **Done.** `VsDualSystem` owns a rewind ring of whole RVSD containers (framebuffers included), `restore_quiet` keeps it, `restore` and `power_cycle` empty it; `RunAhead::run_cabinet_ahead` / `finish_cabinet`; `produce_dual_frame` routes `rewind_held` and the run-ahead depth; the desktop sizes the ring from `[rewind]` (`rewind_budget`, one definition replacing three copies). ADR 0032's amendment gained an outcome note | `vs_dualsystem_rewind.rs` (6 tests, on a cart whose screens change every frame; mutants: a slim sub block in the capture, a loud restore in the step back, both caught); `cabinet_runahead_matches_a_plain_run_on_both_screens` at n = 1, 2 (mutants: no rollback, capture left on, both caught); `a_cabinet_rewinds_and_runs_ahead_through_the_produce_path` (mutants: rewind ignored, run-ahead ignored, both caught) | +| 10 | **Done.** DOC-01..DOC-09 below, each checked against the code; the v1.8.x Android checklist folded into `docs/mobile-v2.9.3-run-sheet.md` as rows G1-G17 (only the rows nothing there covered; one row dropped as now wrong) and deleted, its three live links repointed. Also: the CHANGELOG now says plainly that every earlier AccuracyCoin 100% included the masked `Misaligned OAM behavior` failure | the table below | + +## Records item 10: DOC-01..DOC-09 + +Copied from the 2026-10-06 backlog survey, which lived only in a session +transcript until now, with the v3.1.0 outcome of each. + +| ID | Where | Outcome | +| --- | --- | --- | +| DOC-01 | `to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md` | R1's box and §7 were already reconciled on 2026-10-07; §6 annotated (R1 shipped v3.0.0, the CPU overclock v3.1.0, the stale shifter is ACC-04 in v3.3.0) and §10 retargeted | +| DOC-02 | `docs/compatibility.md` | DualSystem presentation (libretro v2.1.10, web and the mobile bridge v2.9.7), 139/141 dated, VRC6 / 5B / N163 audio Landed, the microphone shipped v2.2.0, MMC5 large PRG-RAM done v2.7.2; the open questions re-checked: CRC32 half-answered (`rustynes-gamedb` is CRC32-keyed), region override still open (FE-11, unscheduled) | +| DOC-03 | `docs/mappers.md` open questions | MMC3 default answered (and its "Sharp (MMC3A)" corrected), MMC5 audio answered, pirate boards answered by policy | +| DOC-04 | `docs/nesdev-hardware-emulation-checklist.md` | AccuracyCoin residuals and the MMC3 #3 residual closed; the stale shifter kept as ACC-04; `vrc24test` confirmed still absent from the corpus | +| DOC-05 | `to-dos/ROADMAP.md` | CI-05 closed as obsolete (no Intel macOS target since v1.6.0, ADR 0009); the open-questions placeholder and the forward ticket table were already done on 2026-10-07 | +| DOC-06 | `docs/libretro/UPSTREAM_SYNC.md` | libretro-super#2131 merged 2026-10-06 (`9c08e5e6f3`); docs#1215 open | +| DOC-07 | `VERSION-PLAN.md` "Planned next" | Already correct: the v3.0.1 cut added its row and the v3.1 line; verified, not edited | +| DOC-08 | stale code comments | `rustynes-android/src/gfx.rs` (Bisqwit is implemented), `wasm.rs` (winit build, audio and IndexedDB all exist), `ntsc.rs` (the composite ladder exists), `netplay_ui.rs` (`mesh_net` exists; the desktop does not use it yet, v3.4.0) | +| DOC-09 | `docs/ios-v1.9.9-readiness.md` | A pointer added under the historical text: FDS, NSF and DualSystem shipped on the bridge in v2.9.7 | diff --git a/to-dos/v1.8.x-on-device-verification.md b/to-dos/v1.8.x-on-device-verification.md deleted file mode 100644 index d26ac6273..000000000 --- a/to-dos/v1.8.x-on-device-verification.md +++ /dev/null @@ -1,101 +0,0 @@ -# Android on-device verification checklist - -Status: living checklist · Applies to: the Android app (`android/app`) on every mobile -release train (v1.8.x "Android" → the v2.0.1–v2.0.4 Timebase re-port → the v2.1.0 store -launch). Referenced by -[`plans/v2.0.x-mobile-finalization-plan.md`](plans/v2.0.x-mobile-finalization-plan.md). - -Host CI (`ci.yml`) remains the authoritative accuracy/determinism gate — the emulation -core is byte-identical on ARM, so **AccuracyCoin is proven on the host, never re-measured -on a phone**. `android.yml` proves the mobile host *links* against the NDK toolchain and -that the UniFFI-binding + Gradle-packaging pipeline is intact. This document is the -**manual, human-in-the-loop** pass that the automated gates cannot cover: it confirms the -app behaves correctly on real hardware before a release is cut. Run it on at least one -arm64 device (a phone) and, when a release touches large-screen / TV / foldable code, on a -tablet/foldable/TV as well. - -## How to run - -```bash -# Build + install the default (foss) debug variant on a connected device. -cd android && ./gradlew :app:installDebug # alias -> installFossDebug (ADR 0025) -# The play variant (freemium / Play-Services surface) when verifying that channel: -cd android && ./gradlew :app:installPlayDebug -``` - -Both flavors must be verified before the v2.1.0 launch (ADR 0025): the **`foss`** build -(no ads, no Google SDKs, fully unlocked — the F-Droid / sideload artifact) **and** the -**`play`** build (freemium + the Play-Services surface). - -## Checklist - -### 1. ROM import + library - -- [ ] Import a `.nes` via the Storage Access Framework picker; it appears in the library - and boots. -- [ ] Import a NES 2.0 ROM; header fields (mapper, region) read correctly in ROM info. -- [ ] Box-art auto-match populates a grid entry (network path; skip on `foss` if offline). -- [ ] Drag/again re-open a previously imported ROM from the library — it resumes cleanly. -- [ ] Confirm the picker offers iNES / NES 2.0 only (FDS / NSF are a post-v2.0.0 - carryover; the bridge must not advertise them). - -### 2. Save-state, rewind, battery SRAM - -- [ ] Write a save-state slot; kill + relaunch the app; load the slot — state restores. -- [ ] Hold rewind; the picture runs backward smoothly and resumes forward on release. -- [ ] A cross-platform `.rns` written on desktop loads on device (and vice versa) — - **within the same format epoch**. A pre-v2.0.0 (pre-Timebase) `.rns` must fail to - load with a clean error toast, never a crash (ADR 0028 / ADR 0002). -- [ ] Battery-backed SRAM (e.g. Zelda) persists across an app restart. -- [ ] Load a pre-v2.0.0 `.rnm` movie: it replays and a **"recorded on a pre-v2.0.0 build"** - warning is surfaced (drained from `NesController.drain_warnings`, ADR 0028); a - current-epoch `.rnm` replays with no warning. - -### 3. Controllers - -- [ ] On-screen multi-touch D-pad + buttons register (including diagonals + simultaneous - A+B); haptic tick fires. -- [ ] A hardware controller (MFi / Xbox / DualSense over Bluetooth or USB-OTG) binds to - P1 and maps South=A / West=B / Start / Select / D-pad. -- [ ] P1–P4 hardware controllers each bind to their own port (Four Score path). -- [ ] Controller hot-plug / disconnect mid-game is handled without a crash or stuck input. - -### 4. Audio + interruptions - -- [ ] Audio plays cleanly with no persistent crackle/underrun at steady 60 fps. -- [ ] Incoming call / notification / another media app ducks or pauses correctly; audio - resumes on return (AudioFocus). -- [ ] Headphone plug/unplug does not wedge the audio thread. -- [ ] Background → foreground (home button, recents) pauses and resumes without desync. - -### 5. Determinism smoke (SMB / Zelda) - -- [ ] *Super Mario Bros.* — World 1-1 renders pixel-correct (no SMB3-style flicker); the - status bar, sprites, and scroll are correct through the first screen. -- [ ] *The Legend of Zelda* — the title screen + overworld render correctly; SRAM save + - reload round-trips. -- [ ] Same input sequence from a save-state produces identical results across two runs - (the determinism contract holds on-device; it is the same core the host CI gates). - -### 6. Accuracy figure to match - -The on-device build runs the **same byte-identical core** as the host, so it inherits the -host accuracy figure — currently **AccuracyCoin 139/141 (98.58%)** on the v2.0.1 catalog -(see `docs/STATUS.md`, the authoritative scoreboard). On-device verification does **not** -re-run AccuracyCoin; it confirms the app surface (I/O, input, audio, lifecycle) around that -core is correct. If a device build ever diverges visibly from the host reference frames, -that is a host-shell bug — file it against `rustynes-android` / `rustynes-mobile`, not the -core. - -### 7. Platform polish (as applicable to the release) - -- [ ] Adaptive layout: phone (compact) vs. tablet/unfolded (expanded two-pane) both lay - out correctly; the NES image letterboxes at any aspect. -- [ ] Foldable fold/unfold mid-game survives with no restart (the emulation thread + GL - surface persist). -- [ ] Android TV / leanback: d-pad navigation reaches every control; the app boots to the - TV home banner. -- [ ] Picture-in-Picture keeps gameplay running; the HUD hides in PiP. -- [ ] `foss` flavor only: confirm the merged manifest has **no** `AD_ID` permission and no - Play-Services meta-data, and that Settings shows no Billing / Play-Games / Cast- - framework surface (ADR 0025). From 3df59e8ddefab1fc0c9ed993c4fa711c2ccae2a8 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 7 Oct 2026 23:32:45 -0400 Subject: [PATCH 09/21] test(accuracycoin): rebuild the misaligned-OAM sub-test; add DMC reload Two sub-test ROMs built from upstream f5f41dc2 (the v3.1.0 re-sync), with build_sub_test_rom.py under upstream's own nesasm.exe through wine, rows measured by subtest_identify. - sprite-eval-misaligned-oam.nes is REBUILT (suite 18, test 6; the legacy build was 17/5). The legacy build carries the upstream bug fixed in adacbc23: FAIL_MisalignedOAM_Behavior jumped out of a JSR'd routine without popping the return address, so a failure in tests 2-7 fell through to a pass. Its `Pass` proved nothing, and it is the ROM the MiSTer sibling's ladder gated that entry by: the sibling's RTL failed test 5 (the X out-of-range mask) for every release before v3.1.0 while that gate read green. Settles at frame 175 (legacy: 211). - dmc-reload-timing.nes is NEW (suite 14, test 6, $0497), for the test upstream added. 15 frames to a verdict against the battery's 4500. It located the sibling's DMC reload defect to one bus cycle (423,976): a $4010 write on the timer's reload edge must set that reload's period. Both pass on this emulator; on the sibling they fail on the RTL without its v3.1.0 fixes (Fail(test 1), Fail(test 5)) and pass with them. Also to-dos/mister/TASKS.md: a board-session item for the $2006 copy delay. Since v3.1.0 the sibling follows the oracle's window-dependent delay (3 dots in a background-fetch window, the next dot elsewhere), which NESdev's constant "1 to 1.5 dots" does not support; it is recorded as provisional (sibling ledger 3.50), and only a board can settle it. Verification: accuracycoin_subtest_provenance 4/4 (it re-measures every row); fast_dotloop_diff 5/5. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../sub-tests/BUILD-PROVENANCE.tsv | 19 +++++++++++++++++- .../sub-tests/dmc-reload-timing.nes | Bin 0 -> 40976 bytes .../sub-tests/sprite-eval-misaligned-oam.nes | Bin 40976 -> 40976 bytes to-dos/mister/TASKS.md | 9 +++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/roms/AccuracyCoin/sub-tests/dmc-reload-timing.nes diff --git a/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv b/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv index 7467f5bca..0dc4bd7ed 100644 --- a/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv +++ b/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv @@ -64,6 +64,22 @@ # does not stand alone or the entry needs the battery's preceding state, and # both are open questions rather than DUT defects. # +# v3.1.0 (the f5f41dc2 re-sync): TWO ROMS FROM THE NEW SOURCE. +# * `sprite-eval-misaligned-oam.nes` is REBUILT from f5f41dc2 (suite 18, +# test 6; it was 17/5 in the legacy build). The legacy build carries the +# upstream bug fixed in `adacbc23`: `FAIL_MisalignedOAM_Behavior` jumped +# out of a JSR'd routine without popping the return address, so a failure +# in tests 2-7 fell through to a pass. Its `Pass` proved nothing, and it +# was the ROM `RustyNES_MiSTer`'s ladder gated that entry by. Settles at +# frame 175 (the legacy build: 211). +# * `dmc-reload-timing.nes` is NEW (suite 14, test 6, `$0497`), for the test +# upstream added in f5f41dc2. It reaches its verdict in 15 frames against +# the battery's 4500, and it is what located the MiSTer core's DMC reload +# defect to one bus cycle (sibling ledger 3.52). +# Both built with `build_sub_test_rom.py` from a clone at f5f41dc2, under +# upstream's own `nesasm.exe` through wine; rows measured by +# `subtest_identify`. +# # RE-BLESSED IN v2.9.5: `ppu-misc-sprites-on-scanline-0` records # `PassWithCode(1)`, the composite-2C02 outcome, where it recorded # `PassWithCode(2)`, "RGB PPU", until then. The core now draws the odd-frame @@ -130,6 +146,7 @@ controller-strobing 13 7 0x045F Controller Strobing Pass 29 legacy-unrecorded cpu-open-bus 0 6 0x0407 Dummy write cycles Pass 31 legacy-unrecorded dma-open-bus 12 0 0x046C DMA + Open Bus Pass 26 legacy-unrecorded dmc-bus-conflicts 12 6 0x046B DMC DMA Bus Conflicts Pass 29 legacy-unrecorded +dmc-reload-timing 14 6 0x0497 DMC Reload Timing Pass 15 f5f41dc2 fc-4step 13 3 0x0468 Frame Counter 4-step Pass 33 legacy-unrecorded frame-counter-irq 13 2 0x0467 Frame Counter IRQ Pass 66 legacy-unrecorded iflag-latency 11 0 0x0461 Interrupt flag latency Pass 29 legacy-unrecorded @@ -153,6 +170,6 @@ sh-shx-9E 9 4 0x044A $9E SHX absolute,Y Pass 27 legacy-unrecorded sh-shy-9C 9 3 0x0449 $9C SHY absolute,X Pass 27 legacy-unrecorded sprite-eval-2002-flag-timing 17 2 0x048D $2002 flag timing Pass 73 legacy-unrecorded sprite-eval-arbitrary-sprite-zero 17 4 0x0458 Arbitrary Sprite zero Fail(1) 29 legacy-unrecorded -sprite-eval-misaligned-oam 17 5 0x045A Misaligned OAM behavior Pass 211 legacy-unrecorded +sprite-eval-misaligned-oam 18 6 0x045A Misaligned OAM behavior Pass 175 f5f41dc2 sprite-eval-oam-corruption 17 7 0x047B OAM Corruption Pass 111 legacy-unrecorded sprite-zero-hit-behavior 17 1 0x0457 Sprite 0 Hit behavior Fail(1) 29 legacy-unrecorded diff --git a/tests/roms/AccuracyCoin/sub-tests/dmc-reload-timing.nes b/tests/roms/AccuracyCoin/sub-tests/dmc-reload-timing.nes new file mode 100644 index 0000000000000000000000000000000000000000..ef79c9da731a157dc32414eef0e5cd11819fa68c GIT binary patch literal 40976 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 PcI+U2Nx4Z$lJvg-klGtc literal 0 HcmV?d00001 diff --git a/tests/roms/AccuracyCoin/sub-tests/sprite-eval-misaligned-oam.nes b/tests/roms/AccuracyCoin/sub-tests/sprite-eval-misaligned-oam.nes index fd87a1ff88f17114d1ebba70ff8f6c0fd26f887d..54242be3c002a9b3f6decbb3d62b2c67a0189002 100644 GIT binary patch literal 40976 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 PcI+U2Nx4Z$lJvg-)-W4R literal 40976 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 diff --git a/to-dos/mister/TASKS.md b/to-dos/mister/TASKS.md index 31307a2ae..2bef78faf 100644 --- a/to-dos/mister/TASKS.md +++ b/to-dos/mister/TASKS.md @@ -292,6 +292,15 @@ Legend: `[ ]` open · `[~]` in progress · `[x]` done **The version number is struck**: this said "v2.6.7", and v2.6.7 shipped "Detent" instead. Seven releases have now passed the slot, so naming one is a prediction rather than a plan. **Unblocks on hardware.** +- [ ] **The `$2006` copy delay, measured on a board** (v3.1.0, sibling ledger + 3.50). Since v3.1.0 the core delays the `v <- t` copy 3 dots when the + second write lands in a rendering line's background-fetch window and 1 + dot elsewhere, because the oracle does; NESdev documents a constant "1 to + 1.5 dots". On the board: run the sibling's `ppu2006pipe080` stimulus (mkrom + program 80) and compare the picture, or a logic-analyser capture of the + PPU address pins, against the core's fetch trace. If they differ, fix the + oracle first and move the pin. **BLOCKED — no board**, with the bring-up + above. ## Rung 7 — memory and mappers From fb8a124359a846d8b0587f75262658726d379df1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 02:07:31 -0400 Subject: [PATCH 10/21] release: v3.1.0 "Bellwether" -- the version, the anchors, the records The cut, made with scripts/release-automation/bump_release.py from the CHANGELOG's new [3.1.0] section (codename chosen by the maintainer). The tool refused once, correctly: to-dos/ROADMAP.md's release chain ended "v3.0.1, the current release" and needed a written v3.1.0 clause rather than a token swap. That clause also corrects the chain's AccuracyCoin sentence, which stated 144/144 as clean. - Version 3.1.0 in Cargo.toml, both Cargo.lock files, the libretro .info, Android (versionCode 30100) and iOS; every "Current release" anchor. - VERSION-PLAN: the v3.1.0 "Bellwether" (current) row, and the planned row removed. - golden/epoch_fingerprint.tsv: last_release_epoch 2 -> 3. From here a moved fingerprint fails until the epoch rises again. - The masked AccuracyCoin score, said where it is claimed. Eleven documents stated 144/144 as the current score (README x4, OVERVIEW x2, SUPPORT, VERSION-PLAN's summary, STATUS, the accuracy ledger, TESTING, frontend.md, the user guide, the ROM licences, to-dos/README); each now reads 146/146 at f5f41dc2 and says the earlier 100% hid a failure. Historical records (release rows, dated measurements) keep their number. - CHANGELOG [3.1.0]: an introduction, the MiSTer core's v3.1.0 work, the records, and the verification below. - One commercial baseline re-blessed: external_coverage's Millionaire (Sachen, mapper 146), checkpoint f900 only (f1100, cycles and audio unchanged). Attributed by running that ROM alone on each v3.1.0 commit: unchanged through 687a5a8a, moved at 53822188. The game database marks its CRC (F8C358D7) PAL, and 53822188 is the PAL emphasis swap. Verification on this tree: - cargo test --workspace --features test-roms --release: 3,262 passed, 0 failed, 11 ignored (epoch fingerprint passes at epoch 3). - external_real_games 60/0, external_extended 137/0, external_coverage 6/0 over 744 staged ROMs after the one attributed re-bless. - markdownlint; the release audits. Not yet: .github/release-notes/v3.1.0.md, which carries the MiSTer ladder and bitstream figures still being measured. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- AGENTS.md | 4 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 64 +++++++++++++++++++ Cargo.lock | 38 +++++------ Cargo.toml | 2 +- OVERVIEW.md | 10 +-- README.md | 12 ++-- ROADMAP.md | 4 +- SECURITY.md | 2 +- SUPPORT.md | 4 +- VERSION-PLAN.md | 8 +-- android/app/build.gradle.kts | 4 +- crates/rustynes-cosim/Cargo.lock | 12 ++-- crates/rustynes-cosim/Cargo.toml | 2 +- .../rustynes-libretro/rustynes_libretro.info | 2 +- .../golden/epoch_fingerprint.tsv | 2 +- ...er_146_Sachen_NINA_Millionaire_Sachen.snap | 2 +- docs/STATUS.md | 8 ++- docs/accuracy-ledger.md | 2 +- docs/dev/TESTING.md | 2 +- docs/frontend.md | 5 +- docs/user-guide/compatibility.md | 5 +- ios/project.yml | 2 +- tests/roms/LICENSES.md | 2 +- to-dos/README.md | 4 +- to-dos/ROADMAP.md | 4 +- 26 files changed, 138 insertions(+), 70 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e6fd5ca59..e4d74e11a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ Enforcement lives alongside the prose: `/ref-proj/` is gitignored/`.dockerignore RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. The accuracy bar is Mesen2 / higan / ares: tight lockstep scheduling at PPU-dot resolution on a master-clock-precise timebase, sub-instruction PPU events visible to subsequent CPU code, and a lookup-table non-linear audio mixer with band-limited synthesis. The frontend is pure Rust (`winit` + `wgpu` + `cpal` + `egui`). -**Current release: v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). **No hardware has run any MiSTer bitstream**: hardware verification moves to a later v3.x release (ADR 0043 supersedes [ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)'s hardware-verified v3.0.0), and the SDRAM pin constraints stay provisional until the SuperStation One's memory is read. Suite counts, mapper matrix and per-release detail live in `docs/STATUS.md`, `CHANGELOG.md` and the GitHub releases; they are deliberately not duplicated here. The earlier release-by-release narrative and other condensed paragraphs are archived verbatim in `docs/history/AGENTS-archive.md`. +**Current release: v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). **No hardware has run any MiSTer bitstream**: hardware verification moves to a later v3.x release (ADR 0043 supersedes [ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)'s hardware-verified v3.0.0), and the SDRAM pin constraints stay provisional until the SuperStation One's memory is read. Suite counts, mapper matrix and per-release detail live in `docs/STATUS.md`, `CHANGELOG.md` and the GitHub releases; they are deliberately not duplicated here. The earlier release-by-release narrative and other condensed paragraphs are archived verbatim in `docs/history/AGENTS-archive.md`. - **Native Android app** — the **v1.8.0 → v1.8.9 "Android"** train (`crates/rustynes-mobile` UniFFI bridge + `crates/rustynes-android` JNI/NDK host + a Jetpack Compose app, ADR 0024): full on-device emulation, multi-touch + P1–P4 hardware controllers, wgpu `SurfaceView` rendering + the shared WGSL shader stack, save-states / battery SRAM, Lua, RetroAchievements, direct-IP + CGNAT/TURN room-code netplay, a box-art ROM library, and platform polish (adaptive / foldable / TV, Material You, capture / PiP / home-screen widget). Distributed as **GitHub-Releases sideload**; a free store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0025's v2.3.0 date; the `foss`/`play` flavour split remains). - **Native iOS / iPadOS app** — the **v1.9.0 → v1.9.9 "iOS" TestFlight train** (`crates/rustynes-ios` Metal + CoreAudio shim reusing `rustynes-mobile` verbatim → UniFFI-generated Swift, ADR 0026): a native SwiftUI shell over wgpu→Metal, multi-touch + GameController, the shader stack, TAS / HD-pack / palettes / per-game DB, Lua + RetroAchievements, LAN + room-code netplay, CloudKit save-state sync, accessibility + EN/ES i18n + ReplayKit + Game Center, and the v1.9.9 creator tools (Cheats, a FOSS-gated read-only debugger, a touch TAStudio piano-roll, foreign movie import, a host audio-depth DSP). Ships to **TestFlight**; a free App Store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0027's v2.3.0 date). Mobile ROM loading was iNES / NES 2.0 only until v2.9.7, which adds FDS (host-supplied BIOS), NSF and the Vs. DualSystem cabinet to the bridge (the Swift half uncompiled; run-sheet rows T1-T12). Readiness record: `docs/ios-v1.9.9-readiness.md`. @@ -236,7 +236,7 @@ that is a reason to add a tenth — not a reason to grow this section back. - `ref-docs/` is immutable. Research updates go in dated supplemental files. - ADRs go in `docs/adr/` (Michael Nygard format). - `rustynes-core` re-exports the public types from the chip crates; downstream consumers (`rustynes-frontend`, `rustynes-test-harness`) should depend on `rustynes-core` rather than the chip crates directly. -- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v3.0.1 "Mortar"** (2026-10-07). **Never claim any version *later* than v3.0.1 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and broke byte-identity and save-state compatibility, by design (v2.9.8 later broke save, movie and API compatibility again, ahead of v3.0.0). The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not. +- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v3.1.0 "Bellwether"** (2026-10-08). **Never claim any version *later* than v3.1.0 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and broke byte-identity and save-state compatibility, by design (v2.9.8 later broke save, movie and API compatibility again, ahead of v3.0.0). The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not. - **Forward plans + roadmap live in `to-dos/`.** `to-dos/ROADMAP.md` (updated in #129) is the planning entry point and frames the release line + "the path to v2.0.0 and beyond"; `to-dos/plans/` holds the per-release plan docs (through `v1.7.0-forge-plan.md` on `main`, plus the staged-forward `v1.8.0-android-plan.md` / `v1.9.0-ios-plan.md` / `v2.0.0-master-clock-plan.md`) + the `to-dos/plans/engine-lineage/` history archive + a `to-dos/plans/research/` reference-mining archive. - The v1.0.0 release + GitHub Pages/CI + post-release record is in `docs/v1.0.0-synthesis-handoff-2026-06-13.md` — read it before touching CI, Pages, or release tooling. Full per-release history is in `CHANGELOG.md`. - **Markdownlint is a CI gate** (pre-commit, pinned `markdownlint-cli v0.49.1`). The pin was v0.39.0 until the v2.6.3 dependency refresh, held because the newer local binary reported rules the pin lacked — chiefly **MD060** (`table-column-style`), which was therefore NOT gated. That is now measured and resolved: MD060's inferred default reads this corpus as style `compact` and reports **1,936 findings across 122 files** and nothing else, so `.markdownlint.json` pins `MD060` to the style actually in use (`leading_and_trailing`), which measures **zero** and rewrites no document. It IS a gate now. Still verify with `pre-commit run markdownlint --all-files` rather than the bare binary — the pin and the local build can drift apart again. `.markdownlint.json` also keeps `MD013`/`MD033`/`MD041` disabled by design (long technical tables, the README HTML banner/``, the HTML-led README). `.markdownlintignore` exempts `ref-docs/`, `ref-proj/` (the reference-emulator clone, now removed from disk but kept in the ignore lists as a firewall guard so it can never re-enter the tree — see the MOST IMPORTANT RULE section above), the vendored `tricnes/` + upstream READMEs, and the frozen `docs/archive/` + `to-dos/archive/` trees — don't lint or reformat those. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c23c54915..7a9984cee 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ **Document Version:** 2.1.0 **Last Updated:** 2026-10-07 -**Applies to:** RustyNES v3.0.1 (the scheduling model is v2.0.0 "Timebase" onward) +**Applies to:** RustyNES v3.1.0 (the scheduling model is v2.0.0 "Timebase" onward) This document fixes the high-level architecture of RustyNES. The per-subsystem specs under `docs/` (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `scheduler.md`) take these decisions as given and elaborate one chip each. After reading this you should know the workspace shape, the scheduling model, the public boundary, and the load-bearing invariants. The canonical, always-current architecture spec is [`docs/architecture.md`](docs/architecture.md); this file is the top-level companion. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e40da2ee..fb295b9f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,16 @@ cycle-accurate core later replaced. ## [Unreleased] +## [3.1.0] - 2026-10-08 - "Bellwether" (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it) + +The first release of the v3.1 to v4.0 line. AccuracyCoin is re-synced to its +newest upstream, which showed that every earlier 100% score included a failure +the old ROM hid; the emulator now passes all 146 for real, and so does the +MiSTer core. Two long-shown options work and travel with movies and netplay +(a CPU overclock, and the sprite-limit switch), PAL emphasis is right, the +alternate MMC3 is selectable and correct, and the Vs. cabinet gets rewind and +run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** + ### Breaking - **Movies and netplay from v3.0.1 are refused, and so are its save states.** @@ -109,6 +119,60 @@ cycle-accurate core later replaced. release's, refusing a re-bless in that state. Until now the rule was kept by hand. +### The MiSTer core (`RustyNES_MiSTer`) + +- **It matches the re-synced AccuracyCoin, all 146 entries.** The new ROM found + the core's version of both emulator defects. A misaligned sprite evaluation + now masks the address after an out-of-range X byte, as the ROM's comments + state; and a `$4010` write on the DMC timer's reload edge now sets that + reload's period. Before them the core differed on two entries; after them the + status vector is identical. +- **A `$2006` write that meets the PPU's address pipeline matches the + emulator** (RTL-1). A new generated ROM lands the copy on every dot of a + rendering line; the core diverged on 28,129 of 331,838 background fetches, + for three reasons measured from the emulator's per-dot trace, and now matches + on all of them. One of the three, a copy delay that depends on where the + write lands, is the emulator's rule and not a documented one (NESdev says a + constant "1 to 1.5 dots"). It is recorded as provisional until a board can + settle it. +- **Ten more AccuracyCoin sub-test ROMs gate the core** (RTL-10), plus the new + `dmc-reload-timing`: 30 of 34 now, the other four out for stated reasons. The + `sprite-eval-misaligned-oam` sub-test ROM is rebuilt from the new source: the + old build's fail path fell through to a pass, so that gate read green every + release while the core failed it. +- **The oracle pin moves to v3.1.0**; every golden that changed is attributed + (AccuracyCoin, the rebuilt sub-test, two new stems) and the other 646 + artifacts are byte-identical. +- **A self-hosted ladder workflow** (`ladder.yml`, manual dispatch only) runs + the whole ladder on the maintainer's runner. CI's Verilator is recorded + (5.032). +- **No hardware has run any bitstream.** + +### Records + +- **The MiSTer contribution page was rewritten in September**, dropping the + "evidence of quality and accuracy testing" sentence the submission case + answered and adding reviewer questions about the developer. The new page and + its consequences are recorded in `ref-docs/`, the checklist is re-scoped, and + `submission-case.md` is refreshed. Two decisions are the maintainer's: how to + show reviewers the code is understood and maintained, and whether the + sibling repository becomes public. +- **Nine stale documents corrected against the code** (DOC-01..09), and the + v1.8.x Android checklist folded into the mobile run sheet. + +### Verification + +- `cargo test --workspace --features test-roms --release`: **3,262 passed, 0 + failed, 11 ignored**. The epoch fingerprint gate passes at epoch 3, now + recorded as the released epoch. +- AccuracyCoin **146/146** at upstream `f5f41dc2`; `nestest` 0-diff. +- The local commercial suites: `external_real_games` 60/0, `external_extended` + 137/0, `external_coverage` 6/0 over 744 staged ROMs. One baseline moved: + *Millionaire* (Sachen, mapper 146), which the game database marks PAL, at one + checkpoint, from the PAL emphasis fix. It was attributed by running that ROM + alone on each v3.1.0 commit, and re-blessed. +- The MiSTer core: LADDER-FILL. + ## [3.0.1] - 2026-10-07 - "Mortar" (the open items closed, one game's graphics fixed, the last MMC3 rule exception tested in the MiSTer core, Rust 1.99 everywhere, every unanswered bot review answered, and a roadmap to v4.0.0) A maintenance release on v3.0.0. It closes the items v3.0.0 left open: diff --git a/Cargo.lock b/Cargo.lock index 145f9a33b..94c71eedd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "3.0.1" +version = "3.1.0" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "3.0.1" +version = "3.1.0" dependencies = [ "cc", "libc", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4358,7 +4358,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "3.0.1" +version = "3.1.0" dependencies = [ "anstyle", "arboard", @@ -4417,18 +4417,18 @@ dependencies = [ [[package]] name = "rustynes-gamedb" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-gfx-shaders" -version = "3.0.1" +version = "3.1.0" [[package]] name = "rustynes-hdpack" -version = "3.0.1" +version = "3.1.0" dependencies = [ "lewton", "png", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bytemuck", "cpal", @@ -4453,7 +4453,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "3.0.1" +version = "3.1.0" dependencies = [ "libc", "rust-libretro", @@ -4463,7 +4463,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4475,7 +4475,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", "rustynes-gamedb", @@ -4491,7 +4491,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "3.0.1" +version = "3.1.0" dependencies = [ "futures-util", "js-sys", @@ -4507,7 +4507,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4519,21 +4519,21 @@ dependencies = [ [[package]] name = "rustynes-probe" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-ra" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "3.0.1" +version = "3.1.0" dependencies = [ "mlua", "piccolo", @@ -4544,7 +4544,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "3.0.1" +version = "3.1.0" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index a46a81c86..00eec722e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ default-members = ["crates/rustynes-libretro"] # `release-auto.yml` reads the `## [X.Y.Z]` line for BOTH the release body # fallback and the title codename — so the date and quoted codename are load- # bearing, not decoration. -version = "3.0.1" +version = "3.1.0" edition = "2024" # The toolchain is 1.99 (rust-toolchain.toml), and every crate inherits this # floor, the libretro path included: the libretro buildbot follows the same pin diff --git a/OVERVIEW.md b/OVERVIEW.md index 3bc8b90c0..e9a3cddfa 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -2,7 +2,7 @@ **Document Version:** 2.1.0 **Last Updated:** 2026-10-06 -**Applies to:** RustyNES v3.0.1 +**Applies to:** RustyNES v3.1.0 --- @@ -22,9 +22,9 @@ RustyNES is the **definitive NES emulator for the modern era** — combining cycle-perfect accuracy with a complete contemporary feature set and the safety guarantees of Rust. It is more than an emulator: it is a platform for NES preservation, competitive online play, tool-assisted speedrunning, and homebrew development. -As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the first designated breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** — the access does not increment, it pulses the load already there: the CHR-during-rendering gate closes at 61,440 of 61,440 pixels, and **no hardware has run any bitstream** — built on **v2.6.22 "Rigging"**, the instruments for the board, built before the board: AccuracyCoin reads back from hardware as bytes rather than as a photograph, the catalog turns out to carry 149 rows and 144 results so the headline no longer depends on when the run was sampled (at the same 144/144), and a `rom_sha256` recorded in every golden manifest and compared to nothing is now rung 0 — built on **v2.6.21 "Steward"**, which brought battery saves, the CHR-during-rendering gate RED at 32,861 of 61,440 pixels, and the deploy loop the runbook prescribed and **v2.6.20 "Telltale"**, itself on **v2.6.19 "Accession"** and **v2.6.18 "Errata"**, built on **v2.6.17 "Terminus"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn. +As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the first designated breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** — the access does not increment, it pulses the load already there: the CHR-during-rendering gate closes at 61,440 of 61,440 pixels, and **no hardware has run any bitstream** — built on **v2.6.22 "Rigging"**, the instruments for the board, built before the board: AccuracyCoin reads back from hardware as bytes rather than as a photograph, the catalog turns out to carry 149 rows and 144 results so the headline no longer depends on when the run was sampled (at the same 144/144), and a `rom_sha256` recorded in every golden manifest and compared to nothing is now rung 0 — built on **v2.6.21 "Steward"**, which brought battery saves, the CHR-during-rendering gate RED at 32,861 of 61,440 pixels, and the deploy loop the runbook prescribed and **v2.6.20 "Telltale"**, itself on **v2.6.19 "Accession"** and **v2.6.18 "Errata"**, built on **v2.6.17 "Terminus"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn. -> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v3.0.1**. +> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v3.1.0**. --- @@ -56,7 +56,7 @@ A one-directional crate graph keeps each chip (`rustynes-cpu`, `rustynes-ppu`, ` | Test | Result | |------|--------| -| **AccuracyCoin** | **100.00% (144/144)** (RAM-direct decoder) — the 2026-09 upstream re-sync grew the battery to 144 assigned tests and v2.6.18 closed the last one (`Advanced Sprite Evaluation :: Frozen OAM2 Increment`), so `KNOWN_FAILING` is empty; the two newest upstream PPU tests ("ALE + Read", "Hybrid Addresses") were closed by the v2.0.3 2-cycle-ALE promotion | +| **AccuracyCoin** | **100.00% (146/146)** (RAM-direct decoder) at upstream `f5f41dc2` since v3.1.0, whose re-sync showed that the 144/144 reported from v2.6.18 to v3.0.1 was overstated, because the older ROM's `Misaligned OAM behavior` fail path fell through to a pass and hid a real failure. Before it: the 2026-09 upstream re-sync grew the battery to 144 assigned tests and v2.6.18 closed the last one (`Advanced Sprite Evaluation :: Frozen OAM2 Increment`), so `KNOWN_FAILING` is empty; the two newest upstream PPU tests ("ALE + Read", "Hybrid Addresses") were closed by the v2.0.3 2-cycle-ALE promotion | | **`nestest`** | **0-diff** against the Nintendulator golden log | | **blargg / kevtris / `mmc3_test_2`** | Green | | **Commercial-ROM oracle** | 60-ROM byte-identical regression gate + extended visual survey | @@ -88,7 +88,7 @@ RustyNES uses **cycle-accurate** emulation rather than scanline-based shortcuts. | Area | What ships today | |------|----------------------| -| **Accuracy** | One-clock scheduler (v2.0.0 "Timebase"), master-clock timebase, AccuracyCoin **144/144 (100.00%)** on a battery that grew to 144 assigned tests at the 2026-09 re-sync, `nestest` 0-diff | +| **Accuracy** | One-clock scheduler (v2.0.0 "Timebase"), master-clock timebase, AccuracyCoin **146/146 (100.00%)** on the battery as re-synced to upstream `f5f41dc2` in v3.1.0 (the 144/144 before it hid a masked failure), `nestest` 0-diff | | **Cartridges** | **191** mapper families incl. expansion audio (VRC6/VRC7-OPLL/Sunsoft 5B/N163/MMC5) | | **Platforms** | iNES / NES 2.0, Famicom Disk System (real-BIOS boot, read/write, multi-side), Vs. System / PlayChoice-10 RGB | | **Online** | Rollback netplay, UDP (native) + WebRTC (browser), 2–4 players | diff --git a/README.md b/README.md index d09cd2c62..97a755a11 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Build Status License: GPL-3.0-or-later Version Rust: 1.99
+ Build Status License: GPL-3.0-or-later Version Rust: 1.99
AccuracyCoin nestest Mapper families Try in browser
Platform

@@ -18,7 +18,7 @@ pure Rust.** It aims at the Mesen2 / higan / ares accuracy bar: one master clock, every CPU cycle a real bus access, and the PPU caught up to each half of it, so a sprite-zero hit, a mid-scanline scroll write or an MMC3 IRQ lands on the exact dot -without a per-game patch. It passes **AccuracyCoin 144/144** and matches the +without a per-game patch. It passes **AccuracyCoin 146/146** and matches the Nintendulator `nestest` log with **zero diff**. Around that core sits a complete, modern platform: 191 mapper families, the @@ -72,7 +72,7 @@ audience: players, RetroArch, mobile and the Rust crates. | | | | --- | --- | -| **Cycle-accurate** | CPU, PPU and APU on one master clock: AccuracyCoin 144/144, `nestest` 0-diff, blargg's CPU, APU and PAL suites | +| **Cycle-accurate** | CPU, PPU and APU on one master clock: AccuracyCoin 146/146, `nestest` 0-diff, blargg's CPU, APU and PAL suites | | **191 mapper families** | NROM through MMC5, the whole VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company, the MMC3 and MMC1 multicarts, Waixing and Nanjing boards, homebrew flash boards with working saves, and a UNIF (`.unf`) loader. Each is classified Core, Curated or BestEffort by the evidence behind it | | **Famicom Disk System** | Real-BIOS boot, writable disks, side swapping, a timed disk-head model and 2C33 wavetable audio | | **Vs. / PlayChoice-10** | Arcade boards in true 2C03 / 2C04 / 2C05 RGB, per-game DIP presets, and Vs. DualSystem two-screen cabinets | @@ -261,7 +261,7 @@ to player 1 automatically. | Suite | Result | | --- | --- | -| **AccuracyCoin** | **144/144 (100.00%)**, read from RAM rather than the screen | +| **AccuracyCoin** | **146/146 (100.00%)** at upstream `f5f41dc2`, read from RAM rather than the screen. The 144/144 reported before v3.1.0 was overstated: the older ROM's `Misaligned OAM behavior` fail path fell through to a pass, hiding a real failure the re-sync found and fixed | | `nestest` | 0-diff against the Nintendulator log | | blargg `cpu_interrupts_v2` | 5/5, and the unstable-store tests 6/6 | | blargg APU (NTSC and PAL) | 11/11 and 10/10 | @@ -357,7 +357,7 @@ detailed in [`docs/architecture.md`](docs/architecture.md) and ## Current release -RustyNES's current release is **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. +RustyNES's current release is **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. **v3.0.0 is the API major** ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). It gathers every breaking change since v2.x, most of them made early in v2.9.8 and @@ -489,7 +489,7 @@ Full attribution is in [`NOTICE`](NOTICE). title = {RustyNES: A Cycle-Accurate NES Emulator in Rust}, year = {2026}, url = {https://github.com/doublegate/RustyNES}, - note = {Cycle-accurate NES emulator; AccuracyCoin 144/144, nestest 0-diff} + note = {Cycle-accurate NES emulator; AccuracyCoin 146/146, nestest 0-diff} } ``` diff --git a/ROADMAP.md b/ROADMAP.md index 4df8a813b..781b361d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,13 @@ **Document Version:** 2.0.4 **Last Updated:** 2026-10-06 -**Project Status:** v3.0.1 "Mortar" released — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core) and **v2.9.9 "Ballast"** (the release candidate for v3.0.0: all four audit scopes re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it with the release-candidate bitstream pair; the tenth release of the v2.9.x line and the sixth of the line to v3.0.0 (ADR 0043, amended)) and **v2.9.8 "Vanguard"** (v3.0.0's breaking changes landed early) and **v2.9.7 "Tandem"** (the desktop's features on the web and on phones). **No hardware has run any bitstream**; the mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). +**Project Status:** v3.1.0 "Bellwether" released — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0) and **v3.0.0 "Cornerstone"** (the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core) and **v2.9.9 "Ballast"** (the release candidate for v3.0.0: all four audit scopes re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it with the release-candidate bitstream pair; the tenth release of the v2.9.x line and the sixth of the line to v3.0.0 (ADR 0043, amended)) and **v2.9.8 "Vanguard"** (v3.0.0's breaking changes landed early) and **v2.9.7 "Tandem"** (the desktop's features on the web and on phones). **No hardware has run any bitstream**; the mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). --- ## Where we are -RustyNES is well past v1.0.0. The current release is **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** +RustyNES is well past v1.0.0. The current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** **This root ROADMAP is a historical snapshot of the v1.0.0 cut.** For the authoritative, current forward roadmap see **[`to-dos/ROADMAP.md`](to-dos/ROADMAP.md)**; for the authoritative current-state pass counts and platform matrix see **[`docs/STATUS.md`](docs/STATUS.md)**; for the full per-release history see **[`CHANGELOG.md`](CHANGELOG.md)**. Many of the "post-1.0 directions" listed further down (mobile, Lua scripting, TAS editor, Vs. DualSystem, HD packs, hosted netplay) have since shipped — the tables below record what was **done at v1.0.0**, not the current feature set. diff --git a/SECURITY.md b/SECURITY.md index 09903d496..970347976 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -The current release is **v3.0.1 "Mortar"**. Built on **v3.0.0 "Cornerstone"** and **v2.9.9 "Ballast"** and **v2.9.8 "Vanguard"** and **v2.9.7 "Tandem"** and **v2.9.6 "Roster"** and **v2.9.5 "Caliper"** and **v2.9.4 "Plumb"** and **v2.9.3 "Handset"** and **v2.9.2 "Candidate"** and **v2.9.1 "Hone"** and **v2.9.0 "Survey"** and **v2.8.4 "Tether"** and **v2.8.3 "Rivet"** and **v2.8.2 "Solder"** and **v2.8.1 "Gasket"** and **v2.8.0 "Bulkhead"** and **v2.7.6 "Recount"** and **v2.7.5 "Tally"** and **v2.7.4 "Pocket"** and **v2.7.3 "Hearth"** and **v2.7.2 "Bankroll"** and **v2.7.1 "Keepsake"** and **v2.7.0 "Palisade"** and **v2.6.23 "Pulse"** and **v2.6.22 "Rigging"** and **v2.6.21 "Steward"** and **v2.6.20 "Telltale"** and **v2.6.19 "Accession"** and **v2.6.18 "Errata"** and **v2.6.17 "Terminus"** and **v2.6.16 "Interlock"** and **v2.6.15 "Warrant"** and **v2.6.14 "Docket"** and **v2.6.13 "Slack"** and **v2.6.12 "Groundwork"** and **v2.6.11 "Exposure"** and **v2.6.10 "Inference"** and **v2.6.9 "Abeyance"** and **v2.6.8 "Arrears"** and **v2.6.7 "Detent"** and **v2.6.6 "Chassis"** and **v2.6.5 "Muster"** and **v2.6.4 "Rubric"** and **v2.6.3 "Mainspring"** and **v2.6.2 "Witness"** and **v2.6.1 "Interleave"** and **v2.6.0 "Assay"** and **v2.5.9 "Overture"** and **v2.5.8 "Blanking"** and **v2.5.7 "Collimation"** and **v2.5.6 "Vestige"** and **v2.5.5 "Raster"** and **v2.5.4 "Escapement"** and **v2.5.3 "Hysteresis"** and **v2.5.2 "Dormant"** and **v2.5.1 "Retrace"** and **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a +The current release is **v3.1.0 "Bellwether"**. Built on **v3.0.1 "Mortar"** and **v3.0.0 "Cornerstone"** and **v2.9.9 "Ballast"** and **v2.9.8 "Vanguard"** and **v2.9.7 "Tandem"** and **v2.9.6 "Roster"** and **v2.9.5 "Caliper"** and **v2.9.4 "Plumb"** and **v2.9.3 "Handset"** and **v2.9.2 "Candidate"** and **v2.9.1 "Hone"** and **v2.9.0 "Survey"** and **v2.8.4 "Tether"** and **v2.8.3 "Rivet"** and **v2.8.2 "Solder"** and **v2.8.1 "Gasket"** and **v2.8.0 "Bulkhead"** and **v2.7.6 "Recount"** and **v2.7.5 "Tally"** and **v2.7.4 "Pocket"** and **v2.7.3 "Hearth"** and **v2.7.2 "Bankroll"** and **v2.7.1 "Keepsake"** and **v2.7.0 "Palisade"** and **v2.6.23 "Pulse"** and **v2.6.22 "Rigging"** and **v2.6.21 "Steward"** and **v2.6.20 "Telltale"** and **v2.6.19 "Accession"** and **v2.6.18 "Errata"** and **v2.6.17 "Terminus"** and **v2.6.16 "Interlock"** and **v2.6.15 "Warrant"** and **v2.6.14 "Docket"** and **v2.6.13 "Slack"** and **v2.6.12 "Groundwork"** and **v2.6.11 "Exposure"** and **v2.6.10 "Inference"** and **v2.6.9 "Abeyance"** and **v2.6.8 "Arrears"** and **v2.6.7 "Detent"** and **v2.6.6 "Chassis"** and **v2.6.5 "Muster"** and **v2.6.4 "Rubric"** and **v2.6.3 "Mainspring"** and **v2.6.2 "Witness"** and **v2.6.1 "Interleave"** and **v2.6.0 "Assay"** and **v2.5.9 "Overture"** and **v2.5.8 "Blanking"** and **v2.5.7 "Collimation"** and **v2.5.6 "Vestige"** and **v2.5.5 "Raster"** and **v2.5.4 "Escapement"** and **v2.5.3 "Hysteresis"** and **v2.5.2 "Dormant"** and **v2.5.1 "Retrace"** and **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a rolling patch cadence rather than maintaining long-lived release branches, so security fixes land in the next patch release rather than being backported. Report against the latest release or `main`. diff --git a/SUPPORT.md b/SUPPORT.md index ee55055e3..452be26ba 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -94,11 +94,11 @@ A: RustyNES is a cycle-accurate NES emulator written in pure Rust, clearing the **Q: Can I use RustyNES now?** -A: Yes. RustyNES is well past its first stable release — the current release is **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** +A: Yes. RustyNES is well past its first stable release — the current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** **Q: How accurate is RustyNES?** -A: AccuracyCoin 100.00% (144/144). The 2026-09 upstream re-sync grew the battery from 141 to 144 assigned tests, and v2.6.18 closed the last outstanding entry (`Advanced Sprite Evaluation :: Frozen OAM2 Increment`). That includes the two older upstream PPU tests ("ALE + Read", "Hybrid Addresses"), which the v2.0.3 2-cycle-ALE PPU-fetch promotion closed — `nestest` 0-diff, and the blargg / kevtris suites green, validated by a byte-identical commercial-ROM regression oracle. See [docs/STATUS.md](docs/STATUS.md) for the authoritative pass-count matrix. +A: AccuracyCoin 100.00% (146/146) at upstream `f5f41dc2`, since v3.1.0. That re-sync also showed that the 144/144 reported from v2.6.18 to v3.0.1 was overstated, because the older ROM's `Misaligned OAM behavior` fail path fell through to a pass and hid a real failure; v3.1.0 fixed it. Before it, the 2026-09 upstream re-sync grew the battery from 141 to 144 assigned tests, and v2.6.18 closed the last outstanding entry (`Advanced Sprite Evaluation :: Frozen OAM2 Increment`). That includes the two older upstream PPU tests ("ALE + Read", "Hybrid Addresses"), which the v2.0.3 2-cycle-ALE PPU-fetch promotion closed — `nestest` 0-diff, and the blargg / kevtris suites green, validated by a byte-identical commercial-ROM regression oracle. See [docs/STATUS.md](docs/STATUS.md) for the authoritative pass-count matrix. **Q: How can I contribute?** diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md index 4cff5b920..54a4f7892 100644 --- a/VERSION-PLAN.md +++ b/VERSION-PLAN.md @@ -1,6 +1,6 @@ # RustyNES Version Plan -**Current release: v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. **No hardware has run any bitstream.** +**Current release: v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. **No hardware has run any bitstream.** RustyNES follows [Semantic Versioning 2.0.0](https://semver.org/). @@ -73,7 +73,6 @@ day.) | Version | Scope | Plan | |---------|-------|------| -| v3.1.0 | The AccuracyCoin re-sync; the CPU overclock and sprite-limit options in movies and netplay; PAL emphasis; opt-in composite artifacts; the NEC MMC3 option; rewind and run-ahead in Vs. dual mode; an epoch fingerprint gate. MiSTer: small RTL items, the self-hosted runner, submission documents | [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md) | | v3.2.0 | Mapper breadth by real titles, the dump corpus, KNOWN_BLANK triage, tier promotions. MiSTer F1: options and about ten cheap families, paddle, Four Score, cheats | [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md) | | v3.3.0 | Phi2 write placement and the sprite-0 stale shifter; wgpu 31 / egui 0.37. MiSTer F2: the SDRAM arbiter, DDR3, save states, rewind; the off-die build becomes the headline | [`v3.3.0-plan.md`](to-dos/plans/v3.3.0-plan.md) | | v3.4.0 | Hosted netplay and the browser RA proxy on Cloudflare, RA hardcore compliance, native 3-4 player netplay. MiSTer F3a: MMC2/4, FME-7/5B, VRC2/4, the Zapper | [`v3.4.0-plan.md`](to-dos/plans/v3.4.0-plan.md) | @@ -166,7 +165,8 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide | **v2.7.5 "Tally"** | Every audit claim closed with a measurement or a reason -- the sixth and last release of the v2.7.x audit line; the core and frontend ledgers have no open row. Of the core audit's twelve hot-path proposals, ten were measured here before any was built, IMP-12 had been measured in v2.3.1, and the phase-`match` fold was closed by reasoning: one combined ceiling probe (seven proposals at their maximum, knowingly breaking correctness) bounded those seven at zero on two clean runs, and IMP-04 run alone twice also measured zero; IMP-05 (CPU inlining + cold-path outlining, whose premise was true) and the branchless palette got their own runs, also zero; IMP-12 was already v2.3.1's G10. **One adopted:** `BlipBuf::drain_all` keeps its capacity between frames, -0.89% frame time on two runs of a frame-then-drain workload (the stock benches never drain audio, which review on #553 exposed after the probe had first been credited with it); it helps the mobile bridge and the wasm build. 18 dead `rustynes_cpu::Bus` methods (found by the compiler: 5 of the audit's 22 were live) and `ApuBus` are `#[deprecated]`, removal decided at v2.9.0. An HD pack refunds the budget of an image that fails after its header. MOB-06 is blocked on UniFFI releasing mutable borrowed bytes. No emulation behaviour change; AccuracyCoin 144/144, nestest 0-diff. | | **v2.7.6 "Recount"** | The v2.7.5 deletions measured one at a time -- a measurement release between the v2.7.x and v2.8.x audit lines. v2.7.5 bounded six of the core audit's proposals (§3.1 A/B/C, the IMP-06 stores, §3.5b, §3.6) only in one combined ceiling; each was re-run alone, twice, with the A/B/A control. Five are zero; §3.5b had never been reached (the bench ROMs are silent, 0 sweep-mute checks per frame), and on `spritecans.nes` bounds at about 0.2%, with the cheap byte-identical candidate 0.67% slower, so it is rejected. Three of the IMP-06 stores re-wrote fields the fast path's guard already requires, and are now a `debug_assert!` pinned by a unit test (no ROM reaches the violating state). §3.1 B and C are dead work that goes with the v2.9.0 deprecation decision. Also ships #554 (contributed): the macOS buildbot jobs build again, and 32-bit Windows, 32-bit Linux and webOS targets were added. Plan: `to-dos/plans/v2.7.6-recount-plan.md` | | **v2.8.0 "Bulkhead"** | The libretro core stops a fault at its own boundary -- the first release of the v2.8.x audit line, from the libretro audit's FFI-safety findings (L-1.1 to L-1.5, L-2.1, L-2.2, L-2.4), each shown failing in a new C-ABI harness that drives the core as a frontend does. The core was built `panic = "abort"`, so its handlers were dead code; it now unwinds on every supported build path (+0.1% instructions per frame; a direct `cargo build --release` still aborts, and now warns and logs it), and every emulating callback contains a panic, stops the game and keeps the console so RetroArch's memory pointers stay valid. `serialize_size` reserves room for the largest expansion device on both ports (a Zapper no longer breaks every later save) and the reader treats an all-zero tail as padding; unload withdraws the memory maps; the vendored `rust-libretro-sys` writes out `retro_game_info`, so the core loads without `GET_GAME_INFO_EXT`. The save state gains the 2A03 internal data bus (older states still load), and the schema audit now covers the bus. No emulation behaviour change; AccuracyCoin 144/144, nestest 0-diff. Plan: `to-dos/plans/v2.8.0-bulkhead-plan.md` | -| **v3.0.1 "Mortar"** (current) | A maintenance release on v3.0.0, plan [`v3.0.1-mortar-plan.md`](to-dos/plans/v3.0.1-mortar-plan.md). Mapper 45's CHR-RAM is unbanked, so *Famicom Yarou Vol.1* draws its menu (T-GA23C-CHRRAM; `EMULATION_EPOCH` 2 refuses v3.0.0 movies and netplay peers). The MiSTer core's dot-0 A12 rule is gated on whether cycle 0 was rendering, found by a new odd-frame stimulus (`mapper4mmc3oddskip080`, plus an /IRQ-rise comparison); new bitstreams at seed 2 / 261007, a release candidate, not hardware-verified. Rust 1.99 and every dependency at its newest, the libretro buildbot included once test pipeline 119614 passed 15/15. Every bot review left unanswered since PR #1 answered (473 verdicts, 80 fixes). The shared Bisqwit NTSC pass recorded as derived; the TriCNES source moved out of the repository. The plan to v4.0.0 written from 29 maintainer decisions | +| **v3.0.1 "Mortar"** | A maintenance release on v3.0.0, plan [`v3.0.1-mortar-plan.md`](to-dos/plans/v3.0.1-mortar-plan.md). Mapper 45's CHR-RAM is unbanked, so *Famicom Yarou Vol.1* draws its menu (T-GA23C-CHRRAM; `EMULATION_EPOCH` 2 refuses v3.0.0 movies and netplay peers). The MiSTer core's dot-0 A12 rule is gated on whether cycle 0 was rendering, found by a new odd-frame stimulus (`mapper4mmc3oddskip080`, plus an /IRQ-rise comparison); new bitstreams at seed 2 / 261007, a release candidate, not hardware-verified. Rust 1.99 and every dependency at its newest, the libretro buildbot included once test pipeline 119614 passed 15/15. Every bot review left unanswered since PR #1 answered (473 verdicts, 80 fixes). The shared Bisqwit NTSC pass recorded as derived; the TriCNES source moved out of the repository. The plan to v4.0.0 written from 29 maintainer decisions | +| **v3.1.0 "Bellwether"** (current) | The first release of the v3.1 to v4.0 line, plan [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md). AccuracyCoin re-synced to upstream `f5f41dc2`, 146 of 146; the new ROM showed that every earlier 100% included a `Misaligned OAM behavior` failure the old ROM hid, and both defects it found are fixed (`EMULATION_EPOCH` 3, BUS section 3, `PPU_SNAPSHOT_VERSION` 13, `.rnm` 6, netplay 7: v3.0.1 states, movies and peers are refused). The CPU overclock and the sprite-limit option work and ride in `HardwareOptions`; PAL and Dendy emphasis swap red and green; the alternate MMC3 is selectable and asserts on a `$C001` reload to 0; differential phase in the NTSC decode; rewind and run-ahead on the Vs. cabinet; an epoch fingerprint gate. The MiSTer core matches the re-synced battery on all 146 entries (the misaligned X mask, the DMC reload edge), matches the emulator on a `$2006` copy landing on every dot (RTL-1, provisional until a board settles it), gates 30 of 34 AccuracyCoin sub-test ROMs, and moves its pin to v3.1.0. **No hardware has run any bitstream.** | | **v3.0.0 "Cornerstone"** | The API major -- the first release of the v3 line (ADR 0043). It gathers every break since v2.x in one place (the header-free game identity, refused older states and movies, the protocol and API changes of v2.8.x-v2.9.9) and adds the last: a core timing epoch that movies (`.rnm` format 5) and netplay (protocol 6, `"RNE6"`) carry, so a version that emulates differently is refused with a reason (ADR 0045); `Cartridge`, `BoardDescription`, `HardwareOptions` and `Movie` `#[non_exhaustive]`; `MapperError::Truncated` renamed `WrongLength`. T-MMC3-BG-A12 closes blargg's `4-scanline_timing` (13/13) in the emulator and on the MiSTer core (`PPU_SNAPSHOT_VERSION` 12). Also: the spectator buffer bounded, mapper 45's power-on CHR-AND (two *Famicom Yarou* menus), the mobile apps on the workspace version, and `CI success` that cannot pass untested. The MiSTer bitstreams ship as a release candidate, not hardware-verified; verification moves to v3.x | | **v2.9.9 "Ballast"** | The release candidate for v3.0.0 -- the tenth release of the v2.9.x line and the last of the line to v3.0.0. All four audit scopes re-run (0 rows regressed; NC-09..17, NF-11..23, NL-11..15, NR-13..17 fixed or decided, NC-17 and NR-14 by the maintainer). T-ORACLE-001 fixed to `4-scanline_timing` sub-test 9: the oracle's `$C001` reload discriminator was the late IRQ; the NESdev rule plus a one-cycle IRQ output deferral replace it (MMC3 section v4). T-MMC5-8X8-SET fixed and wider than the ticket: the MMC5 decodes `$2000`/`$2001` itself and follows the page's CHR table and `$2007` rule, which also fixed *Uchuu Keibitai SDF* (T-COMMERCIAL-GARBLE closed; MMC5 section v6). Audio exact across a save state (APU v5, NL-12). Movie format 4 (NC-10). Mobile save keys migrated to the v2.9.8 identity (NF-21). The sibling's oracle pin moved v2.9.2 -> v2.9.9 with every changed golden attributed, the APU `$4017` / scanline-0 parity in RTL, a frame-sequencer stall found and fixed, and `apu_test` 1-10, `mapper4mmc3irq065` and four checkpoint streams gated. RC bitstreams at seed 6 (261005), byte-identical double compiles. T-GA23C-POWERON searched and left open. | | **v2.9.8 "Vanguard"** | The preparation release for v3.0.0 -- the ninth release of the v2.9.x line. v3.0.0's planned breaks landed early at the maintainer's direction (ADR 0042/0043 amendments of 2026-10-01; the number stays v2.9.x): `Nes::rom_sha256` hashes the bytes after the header, so earlier saves, cheats and movies are not found; `.rns` epoch 3 and BUS section 2 refuse older states with every legacy reader removed; `.rnm` format 3 and the netplay handshake carry every emulation option (ADR 0044) and Four Score P3/P4; `SystemBus` (was `LockstepBus`), the 2.7.5 deprecations, `serialize_header` and the dead /NMI detector removed, `Header` and `FrameInput` `#[non_exhaustive]`; the Vs. database keyed by the new identity. Every one of 748 staged dumps booted and looked at: fixed from their documentation were the game database rewriting correct NES 2.0 headers (10 games), the iNES 1.0 dirty-tail mapper nibble, mappers 19, 64, 78, 105 (`$4017` inhibit), 153, 191 (non-power-of-two images), 218 and 226, Vs. work RAM and the Goonies palette, and PAL regions now reach iNES 1.0 games. The game database's corrections now reach Android, iOS and libretro; power-on options apply before a game's first frame; Power Cycle keeps the PPU/APU settings; an opt-in Famicom console model; the documented NTSC emphasis model. Performance: the /NMI removal -4.9% to -6.1% on three workloads, three campaign candidates adopted (-1.8% to -4.3% together); end to end only shipped `nestest_fast` is established (-5% to -9% vs v2.9.7), the rest a v2.9.9 lead. MiSTer: aspect/integer-scale/crop/2C03-palette menu options (not seen on hardware), a palette-gate, seed 2 kept after re-sweeps. `cargo test --release --workspace --features test-roms,commercial-roms` 3,367 passed / 0 failed; AccuracyCoin 144/144, nestest 0-diff; co-simulation 175/0/1 on-die, 176/0/1 off-die. **No hardware has run any bitstream.** Plan: `to-dos/plans/v2.9.8-vanguard-plan.md` | @@ -201,7 +201,7 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide ## Accuracy milestones (met) -- `nestest` 0-diff, blargg / kevtris suites green, **AccuracyCoin 100.00%** -- 141/141 from **v2.0.3** through v2.6.16, and **144/144** from v2.6.18 once the re-synced catalog's last entry closed (139/139 at the v1.0.0 cut; the v2.0.1 oracle re-sync grew the catalog to 141 assigned tests and briefly opened two PPU gaps, so v2.0.1–v2.0.2 shipped an honest 139/141 until the v2.0.3 2-cycle-ALE promotion closed them), and a byte-identical 60-ROM commercial regression oracle. As of v2.3.0 the AccuracyCoin gate is pinned to an **exact 141/141** (zero failing tests), so a single-test regression — e.g. in the hybrid-address model — fails CI. `docs/STATUS.md` is the authoritative pass-count source. +- `nestest` 0-diff, blargg / kevtris suites green, **AccuracyCoin 100.00%** -- 141/141 from **v2.0.3** through v2.6.16, **144/144** from v2.6.18 once the re-synced catalog's last entry closed (overstated: the older ROM hid a `Misaligned OAM behavior` failure), and **146/146** from v3.1.0 on upstream `f5f41dc2` (139/139 at the v1.0.0 cut; the v2.0.1 oracle re-sync grew the catalog to 141 assigned tests and briefly opened two PPU gaps, so v2.0.1–v2.0.2 shipped an honest 139/141 until the v2.0.3 2-cycle-ALE promotion closed them), and a byte-identical 60-ROM commercial regression oracle. As of v2.3.0 the AccuracyCoin gate is pinned to an **exact 141/141** (zero failing tests), so a single-test regression — e.g. in the hybrid-address model — fails CI. `docs/STATUS.md` is the authoritative pass-count source. ## Git tagging diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4a0187b71..d9410d1fb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,8 +65,8 @@ android { // `scripts/release-automation/bump_release.py` from now on, starting // with the 3.0.0 cut. versionCode = MAJOR * 10000 + MINOR * 100 + // PATCH, so 20909 still rises past 20004. - versionCode = 30001 - versionName = "3.0.1" + versionCode = 30100 + versionName = "3.1.0" // No abiFilters here — set per buildType so release ships arm64 only // while debug keeps x86_64 for the emulator. // PLAY_BUILD is set per-flavor below (`false` for `foss`, `true` for `play`), diff --git a/crates/rustynes-cosim/Cargo.lock b/crates/rustynes-cosim/Cargo.lock index 170bc24e2..03fa409ad 100644 --- a/crates/rustynes-cosim/Cargo.lock +++ b/crates/rustynes-cosim/Cargo.lock @@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "libm", @@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "lz4_flex", @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "rustynes-cosim" -version = "3.0.1" +version = "3.1.0" dependencies = [ "rustynes-core", "sha2", @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "thiserror", @@ -137,7 +137,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "rustynes-apu", @@ -146,7 +146,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.0.1" +version = "3.1.0" dependencies = [ "bitflags", "libm", diff --git a/crates/rustynes-cosim/Cargo.toml b/crates/rustynes-cosim/Cargo.toml index 392909764..ac6875959 100644 --- a/crates/rustynes-cosim/Cargo.toml +++ b/crates/rustynes-cosim/Cargo.toml @@ -8,7 +8,7 @@ description = "RustyNES as a co-simulation oracle for an external HDL device-und # The duplication is PINNED, not merely noticed: `cosim_manifest_audit.rs` in # `rustynes-test-harness` asserts these values still match the workspace's, so # drift fails a test instead of accumulating quietly. -version = "3.0.1" +version = "3.1.0" edition = "2024" rust-version = "1.99" license = "GPL-3.0-or-later" diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info index 39129e6a2..c5c2500c0 100644 --- a/crates/rustynes-libretro/rustynes_libretro.info +++ b/crates/rustynes-libretro/rustynes_libretro.info @@ -5,7 +5,7 @@ supported_extensions = "nes|fds|unf|unif" corename = "RustyNES" license = "GPLv3+" permissions = "" -display_version = "v3.0.1" +display_version = "v3.1.0" categories = "Emulator" # Hardware Information diff --git a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv index c5587f46e..1fd1fa497 100644 --- a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv +++ b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv @@ -4,7 +4,7 @@ # the last release shipped, set by hand at each release cut: output may # move only while EMULATION_EPOCH is above it. # epoch 3 -# last_release_epoch 2 +# last_release_epoch 3 # rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158 nes-test-roms/ny2011/ny2011.nes 600 118f6b763cc1c58a dda9e74ae2696052 538041de1d7bd79f 17838523 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_146_Sachen_NINA_Millionaire_Sachen.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_146_Sachen_NINA_Millionaire_Sachen.snap index 9d6950645..dd1fdbbb9 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_146_Sachen_NINA_Millionaire_Sachen.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_146_Sachen_NINA_Millionaire_Sachen.snap @@ -9,5 +9,5 @@ fb_bytes=245760 cycles=37902150 audio_samples=1005322 audio_fnv1a64=f087542b1f027e9a -checkpoint f900 fb_fnv1a64=a71d0c1c59be672e +checkpoint f900 fb_fnv1a64=7b6fa7c63d09e90e checkpoint f1100 fb_fnv1a64=02035ffbeb95f997 diff --git a/docs/STATUS.md b/docs/STATUS.md index 2d345ac96..7678a5a55 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # RustyNES — Project Status Matrix -> **Current release: v3.0.1** (2026-10-07) — **"Mortar"**, a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** (2026-09-29) — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** (2026-09-29) — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** (2026-09-29) — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. The mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). Built on **v2.9.2 "Candidate"** (2026-09-28) — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** (2026-09-27) — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** (2026-09-26) — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** (2026-09-26) — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** (2026-09-25) — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** (2026-09-25) — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** (2026-09-25) — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** (2026-09-25) — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** (2026-09-24) — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** (2026-09-24) — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** (2026-09-24) — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** (2026-09-23) — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** (2026-09-23) — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** (2026-09-23) — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** (2026-09-23) — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** (2026-09-20) — the access does not increment, it pulses the load already there. A `$2007` access during rendering pulses the rendering pipeline's existing load rather than computing a private increment, and resolving against that same value is what closed the CHR-during-rendering divergence in the MiSTer sibling: `chrram-live` went from **32,861 of 61,440 differing pixels**, red for eight releases, to "All 61440 pixels match", and `chrram-fetch` from 21,941 to 18. Eleven prior variants had been measured correctly and the conclusion drawn from them was wrong — they swept what the `$2007` arm computes and never what it computes it from. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction** and were re-run anyway. The bitstream is re-cut at fitter seed 4 (+0.421 ns setup / +0.112 ns hold) and **no hardware has run it**. The board session is now **v2.9.2** and the hardware-verified core **v3.0.0**, after three audit lines ([ADR 0041](adr/0041-hardware-release-is-v3.0.0.md)). Built on **v2.6.22 "Rigging"** — the instruments for the board, built before the board. **No hardware has run any bitstream**; the session with the board (then planned as "Shakedown"; since ADR 0041, v2.9.2) is the hardware half and this is the non-hardware half of it, cut separately so that name keeps meaning what it says. AccuracyCoin now reads back from hardware as BYTES rather than as a photograph — a mirror ROM copies its result vector into the battery-backed save window, with a byte budget that proves the patch moved nothing and a simulation control that proves it changed no answer. The catalog turned out to have **149 rows and 144 results**: five `Power On State` rows share upstream's omit-sentinel, and counting them made the headline a function of *when the run was sampled*. **144/144 and nestest 0-diff are unchanged** — the count stopped depending on the sampling instant. And the `46199ae4` re-sync turned out to have been half-done: three goldens described a ROM no longer in the tree, which no gate could see because `rom_sha256` was written into every manifest and compared to nothing. Built on **v2.6.21 "Steward"** — the board arrives, and the core is not ready for it. Battery saves exist for the first time; the CHR-during-rendering gate the retrospective audit asked for is added and is **RED at 32,861 of 61,440 pixels**, with the obvious fix measured insufficient at 715 recovered; two gates existed that nothing ran; and the deploy loop the runbook prescribed was built, which found two defects in its own spec. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.20 "Telltale"** — the counter had no reader, and two knobs turned out to be one decision. The FPGA core's OAM2 address counter was write-only for a release, so `$2004`'s post-fetch rest value was a hardcoded index 0; making it observable took the DUT to **AccuracyCoin 149 of 149** and the co-simulation ladder to **150 of 150, 0 failed**. Restoring the increment window v2.6.19 had narrowed then broke sprite rendering by six pixels, because the window and the deleted dot-257 latch are ONE decision with two self-consistent answers and only one of them is the hardware's. The shipped configuration restores the latch: window 256, the dot-257 freeze latch back and consumed by sprite fetch, and `$2004` reading the live counter. A CHR-RAM write gate was added. **v2.6.21 corrects this sentence: it did NOT close the coverage the audit named.** The audit named writes *while rendering is enabled*, and PROGRAM53 writes with `PPUMASK = 0` and renders afterwards. The real case is gated at v2.6.21 and is RED -- 32,861 of 61,440 pixels. The emulation core is unchanged, so **144/144 and nestest 0-diff hold by construction**. Built on **v2.6.19 "Accession"** — the DUT absorbs two releases of oracle behaviour, and the seed rule catches something for the first time. The FPGA core implements the OAM2 address counter it never had and stops acting on a `$2001` change that lands during dot 256 -- AccuracyCoin on the DUT **148 of 149**, the ladder **147 of 147**. The fitter pin is re-derived over ten seeds and stays at 3. (The contemporaneous claim that seed 1 stopped closing is withdrawn -- that sweep recompiled in place and was order-dependent; seed 1 closes, and seed 8 is the one that does not.) The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.18** (2026-09-12) — **"Errata"**, the recorded cause was wrong in three ways, and the last AccuracyCoin entry closes at 144/144 -- a `$2001` change during dot N must not act on dot N, at the dot-256 vertical increment and the dot-339 OAM2 reset. Built on **v2.6.17** (2026-09-11) — **"Terminus"**, the accuracy battery grows to 144 assigned tests and one of the new ones names the dots a write lands on -- this core is two dots early, measured twice on two independent `$2001` writes, and moving it is refuted by the gate this version wrote in advance, so the documented compensation stays and 143 of 144 ships. Built on **v2.6.16 "Interlock"** (2026-09-04) — the arbiter's numbers describe a stimulus, not the console -- and the console meets the CPU's deadline at zero margin, on every one of 240,303 requests, where the gate said it missed by four. Built on **v2.6.15 "Warrant"** (2026-09-04) — the claims v2.7.0 will make become checkable, and the instrument pays the oracle back. Built on **v2.6.14 "Docket"** (2026-09-03) — the submission checklist becomes auditable, and auditing it finds five boxes already true, two ticked on evidence that expired, and one that could never have been ticked honestly. v2.7.0 IS the submission, so the list in `to-dos/mister/contribution-checklist.md` decides whether the core is ready -- and it had 30 boxes, 16 unticked, and FOURTEEN OF THOSE SIXTEEN saying nothing at all about why. That ambiguity is the defect: an unticked box with no reason cannot be told apart from work outstanding, work blocked outside this repository, and WORK ALREADY DONE AND NEVER TICKED, and the third case occurred five times -- the provenance CI job, the SPDX sweep, the firewall statement, the AccuracyCoin vector and the preservation-value case were all true and all unticked, so the list reported the project as further from submission than it is by a fifth of its own length. ONE BOX COULD NEVER HAVE BEEN TICKED HONESTLY: it asked that `docs/provenance.md` state "that no NES core was ever opened", and that same document's section Do not self-certify forbids exactly that class of finished claim, so satisfying the box required writing the one sentence the provenance rules exist to prevent -- a wrong requirement rather than a missing tick, and only reading every item found it. RE-MEASURING THE TICKED HALF found two more, which is v2.6.9's lesson in a different document: `RustyNES.sdc` still said "there is exactly one core clock", true of v2.6.3 and false since v2.6.13, which added a 4x SDRAM clock and a phase-shifted pin clock that the shipped timing report names alongside it; and the `.qsf` entry said all 109 pin assignments come from `sys/sys.tcl`, where 109 is what that script supplies before stopping short of the I/O board, so a core must also source one of two 36-assignment variants -- 145 in total, from two scripts. THE ALARMING READING OF THE FIRST WAS CHECKED BEFORE IT WAS WRITTEN DOWN: it looks as though the framework's `set_clock_groups -exclusive` might be cutting the console-to-SDRAM crossing and leaving ADR 0039's safety argument unfalsifiable, and it is not -- exclusive cuts paths BETWEEN groups, all three outputs match one glob, and v2.6.13's own -24.769 ns measurement of that crossing is only observable because those paths are analysed. THE NAMING DIVERGENCE IS MEASURED RATHER THAN PARAPHRASED, from `Main_MiSTer/file_io.cpp` instead of the wiki: `get_display_name` searches for the literal underscore-two-zero and TRUNCATES THE DISPLAY NAME THERE, taking the rest as a datecode, and `DirentComp` groups by that truncated name -- so a version-named bitstream makes every release a SEPARATE CORE ENTRY, named for the version rather than the core, ordered alphabetically, which puts v2.6.9 after v2.6.13. The fix is one line and is NOT taken here, because version-naming is a maintainer decision with a stated rationale and reversing it is not an audit's call. THE TASK BOARD HAD THE SAME DEFECT AND ONE ROW WORSE: four delivered items never ticked, a hardware row still naming a version seven releases past, and an SDRAM row whose precondition -- "after a board exists" -- v2.6.13 simply did not follow, having accepted the controller against a behavioural model written from the datasheet instead; that is rung 7's own recorded lesson repeating one row below where it is written, since the blocker applied to hardware ACCEPTANCE rather than to building the thing. A CLAIM v2.6.13 SHIPPED IS RETRACTED: answering a review finding, I wrote that MMC1 was the sixth approved family and not yet implemented, and the cartridge decodes mapper 1 at three sites with a registered gate green since rung 7 opened -- asserted from memory inside a reply correcting somebody else's reading of the same line. The gate that keeps all this true asserts a SHAPE rather than a judgement and is demonstrated by five mutations, one of which proves the continuation-line folding load-bearing by producing nine false violations without it. The bitstream is BYTE-IDENTICAL to v2.6.13's, which is the point: the only sibling change is a comment, and an identical artifact demonstrates it. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.13 "Slack"** (2026-09-03) — the cartridge outgrows the die, and three consumers want the same bus. An SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge, all written from the AS4C32M16SB-7 datasheet revision 1.4 -- no third-party controller read, ADR 0037 applying. The budget the previous step worked to was a single figure read off the fetch structure and never measured; `nes_top`'s `CHR_LAT` sweep asks the console directly, and there are THREE answers: a background or sprite fetch tolerates 28 cycles and uses 17, the PPUDATA data port tolerates 8, and the CPU sampling at mc7 has 24 -- four cycles of every budget going to the console-domain crossing, which is not optional, because publishing a runtime modulo combinationally into an 11.64 ns domain costs -24.769 ns of setup. The PPUDATA port could never fit, half its budget being the crossing, so it leaves the shared bus entirely: `ppu2c02` gains `BUFFER_HANDSHAKE` and fills its read buffer through a port of its own on the arbiter, affordable because the CPU does not read that buffer until its next PPUDATA access. THAT FIX SHIPPED A DEFECT ONLY A BANKED CARTRIDGE COULD SEE -- the request carried the RAW fourteen bits the PPU presents, which is right by coincidence on NROM because the mapper's translation is the identity there, and reads BANK ZERO on everything else; `ppu-misc-2007-stress` passed off-die throughout while the two CNROM gates read a zero byte where the oracle reads one, one and two. The fix REMOVES the address rather than correcting it: `cart.sv` already publishes the translation for the fetch path, so the request carries none and there is one source of truth for where CHR lives instead of two that agree only on NROM. A DELETION WAS THEN REFUTED BY ONE CYCLE: with the address fixed, a control issuing at a flat +7 passed both gates, which read as the anti-contention deferral buying nothing, so it was removed -- and the deployed code issued at +6, and both gates failed again. The control and the code differed by a single cycle, which is the measurement of how thin the CPU's off-die deadline is and the concrete argument for scheduling the bus rather than arbitrating it. Two INFERRED LATCHES that Verilator cannot see: `ppu2c02` assigned two signals only under `BUFFER_HANDSHAKE`, so in the shipped on-die build the only assignment either reached was the reset branch, and a variable holding its previous value on every live path is a latch -- Quartus said so twice while the lint gate stayed green, and the comment beside them asserted the defect as a virtue ("outside BUFFER_HANDSHAKE neither signal ever moves", which is true and is exactly the condition). And a defect in the HARNESS: `USE_SDRAM` reaches Verilator as `-G` rather than as a file, so make ran ONE binary under both configurations' names and a log labelled on-die was the off-die build, reproducing the off-die failures exactly -- closed with a stamp-file prerequisite, demonstrated by mutation. THE OPEN ROW: accesses no longer auto-precharge, a hit costs 6 cycles against 10 for a miss, tRAS's 120 us MAXIMUM is respected by an early close in idle, and two defects came out of the rewrite -- re-entering idle with the request still asserted issued every access TWICE, and subtracting one from CAS the way the other waits are computed broke every read to all zeroes, CAS being "data appears at cycle N" rather than a command-to-command gap. Two MiSTer tickets close: `sdram_sz` is consumed VALIDITY BIT FIRST, so `absent` is deliberately not `!present` and a power-on all zeroes cannot read as "no board" (gated exhaustively over all 65,536 values), and `status_menumask` is computed rather than tied off, greying Reset whenever the console is already held in reset. OFF THE DIE THE CONSOLE PASSES 142 OF 142, every gate the on-die build passes, with better timing margin and 384 fewer M10K blocks -- and it still SHIPS ON the die, because an off-die core cannot run at all without the SDRAM add-on while rung 7's five mapper families fit on the die at 468 of 553 blocks. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.12 "Groundwork"** (2026-09-02) — the bitstream was an NROM-only console. Rung 7 landed five mapper families and 142 co-simulation gates verify them, and the layer that turns that RTL into a bitstream was never told: `rtl/emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND -- mapper 0 for EVERY cartridge, `prg_8k_count = 0` collapsing PRG to an 8 KiB window, and CHR forced to RAM. The declared 256 KiB PRG and 128 KiB CHR were implemented as 8 KiB each; connecting three wires takes block memory from 666,061 to 3,680,717 bits and timing still closes at all four corners. NOTHING COULD HAVE CAUGHT IT: simulation cannot, because `emu.sv` is not in the testbench file list and the harness drives those ports itself, so all 142 gates exercised a correctly-configured cartridge; and Quartus DID say so three times, in messages that cite an INSTANCE path rather than a file and are absent from the "0 errors, N warnings" tally, so the existing checker read 0 of 125. Two gates close that -- one fails on an unconnected pin of any module this repository declares, the other pins the warning SET rather than its count -- and both are demonstrated to fail by mutation. The `hps_io` tie-off audit that followed raised nine `T-MISTER-*` tickets, and annotating each with a blocker turned "none of these landed" into a measurement: not one is blocked on EFFORT, so the list is the rung-6 agenda rather than a backlog. `T-MISTER-SAVE` was attempted and refuted -- every save route terminates in `hps_io`, which no gate here instantiates. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.11 "Exposure"** (2026-09-02) — a picture is a gate the ladder did not have. All 141 co-simulation gates THEN IN THE SUITE were green (it ends this release at 142, the one it added) and TWO OF SIX commercial games rendered wrong -- a CHR-RAM write was taking the shared-pin composite address built for FETCHES instead of `v`, so the layout was right and the tiles were scrambled. The split is exactly CHR-ROM against CHR-RAM, which named the mechanism before any tracing, and a CONTROL says it is not v2.6.10's regression: the pre-M10K-fix RTL differs by the IDENTICAL 16,565 pixels, so the defect dates from the cartridge landing in v2.6.9. It was not UNREACHED -- the DUT asserts `chr_wr` 9,600 times in the Battletoads run -- it was UNCOMPARED: only THREE of the 141 gates compare a framebuffer, all three ship CHR-ROM, every other gate is CPU-side, and AccuracyCoin, the widest gate in the suite, is CHR-ROM too. The rung-7 gates' own comment says what they are for -- "these gates are about BANKING and nothing else" -- and it was accurate, and it was the whole coverage. Six commercial titles now render byte-identically to the oracle over all 61,440 pixels, published as a montage built by a script that REFUSES to publish a tile that differs from the oracle. The v2.6.10 bitstream carries the defect and a published version is immutable, so the corrected `.rbf` ships here. The same release finds EIGHT release leads describing v2.6.10 with v2.6.9's summary, and v2.6.9 gone from the lineage entirely, with `docs/STATUS.md` naming v2.6.10 under the codename "Abeyance" -- every existing check passing CORRECTLY, because they pin the version TOKEN and the token was right. Prose cannot be audited; an ORDERING can, so two gates are added and both are demonstrated to fail by mutation. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.10 "Inference"** (2026-09-01) — the cartridge meets the synthesiser. Five cartridge boards verified across 141 co-simulation gates had **never been through Quartus**, and Analysis & Synthesis refused the design: `chr` was written from TWO `always_ff` blocks, which cannot infer as one M10K, so 128 KB of CHR stayed in flip-flops -- **1,048,576 registers against roughly 166,000**. Simulation cannot ask this question: Verilator accepts both forms without complaint. It is v2.6.6's finding one layer out -- that release established that an M10K read is REGISTERED, this one that a correctly registered memory still will not infer with two writers. The fitter was also throttling itself under Auto Fit while the `.qsf` carried no optimisation assignments at all: at full effort **all six seeds close** where two had failed, so the effort settings move the whole distribution across zero and the seed only picks where in it you land -- and the project had been pinned to seed 4, the WORST of the six. Pinned at seed 3, +0.531 ns setup and +0.099 ns hold, byte-identical across two independent compiles. The bitstream v2.6.9 could not produce ships here. Built on **v2.6.9 "Abeyance"** (2026-08-31) — an exclusion hides improvement as well as regression, and both denied co-simulation streams close. The larger one was never the console: `apuconflict039` had been carried for seven releases as a declared diagnostic whose bus surface "carries nine divergences BY DESIGN", and the nine were a defect in the HARNESS -- on a cycle the CPU is held, the testbench built its record's bus data from a stale local rather than from the RTL's own latch. Taking it from the latch makes the stream IDENTICAL on all 357,361 overlapping cycles and all 88 checkpoints, and the local is now dead and deleted. The phrase "by design" is what stopped anyone re-checking it, because it reads as a property of the thing under test when it was a property of the instrument reading it. The other stream differs on EXACTLY ONE cycle, a documented and attributed OAM-corruption asymmetry -- and carrying that needed an instrument the suite did not have, because the PLANNED mechanism was refuted by its own mutation pass: an allowance by checkpoint index cannot work on a rolling hash, since one divergent cycle poisons every checkpoint after it, so allowing the first differing window simply moved the failure to the next one and allowing the rest is the all-or-nothing deny it was meant to replace. A per-cycle nine-field comparator with a scoped allowance costs ONE cycle of coverage instead of seventy-one checkpoints -- 357,360 of 357,361, against nothing at all before -- and it fails BOTH ways, so a DUT that improves cannot leave a stale allowance quietly hiding coverage; six mutations confirm it, including a cycle outside the compared window being REFUSED rather than allowed to match nothing. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction and were re-run anyway. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.8 "Arrears"** (2026-08-31) — the gates the previous release fixed and never widened. A deny list is an assertion about the THING UNDER TEST, so v2.6.7 changing both the DUT and the harness re-opened every exclusion -- and nothing re-measured one. FOUR of the six denied checkpoint streams were passing (`irqlat048`, `ppuvbl023`, `ppuvbl024`, `ppuvbl025`), and THREE of those were not run by the suite at all, so removing them from the deny list was INERT until they were also iterated. The nestest gate compared 265,000 cycles against a 5,062,688-cycle golden -- correct while caveat C6 was open, left behind the moment it closed -- and all 5,062,680 overlapping cycles now match, with the window DERIVED from the manifest. CAVEAT C4 CLOSES by demonstration: nestest raises `nmi_line` on 3,592 cycles and had no nine-field comparison at all, so wiring it makes an `o.nmi_line` mutation CAUGHT at checkpoint 28 of 1,237 while the bus gate passes the identical run. Suite 128 passed, 0 failed, 0 skipped; nine-field comparisons 51 -> 57. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no board is attached, confirmed by checking. Built on **v2.6.7 "Detent"** (2026-08-30) — the bitstream becomes a published release artifact and a one-cycle disagreement is pinned to the cycle it happens on. Every release from here ships a `.rbf` -- committed to the sibling's `releases/` and attached to the GitHub release on BOTH repositories -- reversing v2.6.6, which produced one and withheld it because no hardware had run it: the MiSTer distribution mechanism reads that path out of the REPOSITORY, so an empty `releases/` describes an undistributable core rather than a cautious one, and the caution moves from an absence into a disclosure naming what the ladder cannot reach by construction (the PPU gate compares the pre-palette index and the APU gate per-channel integer levels, so the palette, the video timing constants, the audio absolute level and its band-limiting all sit downstream of every gate). The build is REPRODUCIBLE and that is now measured rather than argued -- a from-scratch compile and an incremental one produce a byte-identical bitstream -- which is also how v2.6.6's published slack figures came to be WITHDRAWN: no corner of a clean rebuild reproduces them, the innocent explanation (a different timing corner) was checked first and refuted, and the correct pair is +0.108 ns setup and +0.042 ns hold at the binding corner. THE RELEASE GATE WAS READING THE WRONG CORNER -- Slow 100C is not the binding one on this design, so a bitstream failing at Slow -40C would have passed while the gate reported three times the real margin -- and the checker that reads it was wrong twice before mutation found both: it first extracted ZERO rows from both summary tables and reported that as "no negative slack", then, once fixed, reported FOURTEEN clocks from a report emptied of its data, having run past the closing rule into the next tables. Caveat C2 splits in two. The first residual was a TRACE OBSERVATION POINT -- the harness built its record after eight of a CPU cycle's twelve master clocks while the oracle reads at end-of-cycle, and the frame-counter interrupt asserts on the final edge -- and closing it took checkpoint comparisons from 3 to 11 and failures at one checkpoint from 30 to 3. The second is REAL, and the FIRST fix for it was REFUTED in a way that found the right one: moving all four effects of the write one cycle later to match the oracle drops blargg from 11/11 to 4 of 11, one of the seven being the ROM written to probe exactly that timing. Read as a measurement that PROVES the sequencer's maturation is correctly placed, which leaves only the other effects the same write schedules -- so separating ONLY the interrupt clear lands it at write+3, the documented cycle, while the frame counter's zeroing stays put. Checkpoint streams go from 11 identical to 52 of 58 and the suite from 87 to 122, with blargg still 11/11 and the bus still matching on all 2,680,239 overlapping cycles. The checkpoint gate is registered over 51 comparisons and STATES ITS BLIND SPOT: not one gated golden ever raises an NMI, so it cannot catch an nmi_line defect, and the attempt to close that hole found a FOURTH divergence cluster that three goldens had been hiding inside a "26 skipped" tally line. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.6 "Chassis"** (2026-08-29) — the console becomes a MiSTer core -- `sys/` vendored byte-identical against `Template_MiSTer@3ea1134c` (57 files, 0 content differences), a top level, a clock, a palette, video sync and an audio mixer, compiled by Quartus 17.0.2 into a Cyclone V bitstream with 0 errors, timing CLOSED, and a warning count taken from 111 to THREE -- all three inside the vendored framework or Quartus's own megafunction, none of them citing this project's RTL (worst setup +0.086 ns and worst hold +0.096 ns at the binding corners, seed 3 -- v2.6.7 also withdraws the +0.363/+0.245 v2.6.6 published, which a clean rebuild of that configuration reproduces at no corner, TNS 0.000 on every clock; the console's own clock +13.514 ns at an Fmax of 30.26 MHz against the 21.477272 MHz it needs). The emulation core is unchanged, so the co-simulation suite is an ACCEPTANCE CRITERION rather than a formality -- 87 passed, 0 failed -- and it earned that immediately, because the cartridge memories had to be rewritten: an M10K read is REGISTERED, so 40 KiB of asynchronously-read cartridge was 393,216 registers against roughly 166,000 available, under a comment claiming it inferred block RAM from the source style alone, and the README had stated the correct rule since v2.4.3. Two defects were found only by asking whether the outputs would work on real hardware: the audio would have been a full-scale DC rail, because the mixer output is unipolar with silence at zero and the framework maps unsigned silence to -32768, and two OSD scanline options did nothing because VGA_SL was tied to zero. And a convention enforced by a glob has no error message: sys_top.sdc groups the core clock by matching the hierarchical name pattern *|pll|pll_inst|altera_pll_i|*, so a differently-named PLL matched no group at all and every crossing to the framework audio, HDMI and HPS domains was analysed as synchronous -- -13.901 ns of slack and -422,601 ns of TNS on a design whose Fmax was already above requirement, with the compile succeeding and the Assembler reporting 0 errors and 0 warnings throughout. Built on **v2.6.5 "Muster"** (2026-08-29) — rung 5 closes — the AccuracyCoin status vector is identical entry for entry across all 146 entries, with 146 of 146 executed on both sides and none NotRun, where the same gate read 5 of 146 at the version's start. A muster is a roll call where every name is called AND answered, which is the two-clause acceptance exactly. Five PPU defects close the last six differing entries and four were invisible to every gate that existed when the version opened: the background shift registers' RELOAD and their shift clock need SEPARATE gates (with one shared gate the serial-in test was not merely failing but ARITHMETICALLY UNREACHABLE, since reload dots are absolute and the reload discards the low seven bits, so a serial-in one can never reach bit 7 on any alignment — and modelling both structures reproduces BOTH measured shifter values); the sprite X counters are NOT gated on rendering, which AccuracyCoin states outright and the ROM that states it passes either way, because it expects no hit at X=254 and a sprite shoved 18 dots right is also off the line; the PPUADDR second-write v-copy is DELAYED, as the wiki says inside the write sequence itself, swept 1 to 4 dots against a control at 8 and 12 that fails; and the pre-render line CLEARS secondary OAM, without which scanline 0 draws what scanline 239 left — no sprite can ever render on scanline 0, because OAM Y is one less than the display row, and a sprite-0 probe over the full 134 M-cycle battery found 24 hits with four of them there; and the octal latch holding across the read dot, which is verified by exactly ONE gate and was unverifiable until the v-copy delay landed, the two composing the hybrid address together and neither producing it alone. A DIAGNOSIS IS RETRACTED: the residual was read as a two-dot CPU/PPU alignment error from comparing dot spans across two instruments, and at the committed alignment the two consoles execute identical pc, bus_addr and bus_access for 1,695,131 cycles while a two-dot shift moves the first fork back to 593,228 and takes the differing share from 5.13% to 66.80%. The oracle changes on the default path, so AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff are VERIFIED, not asserted. Built on **v2.6.4 "Rubric"** (2026-08-26) — OAM DMA lands and all nine AccuracyCoin disagreements close, every rule that closed the last three stated by the test ROM and by neither nesdev page — and then the gate that certified them is measured to cover 88 of 146 entries. The emulation core is unchanged. Built on **v2.6.3 "Mainspring"** (2026-08-25) — the DUT runs on one master clock, and four enables that were never enabling — plus AccuracyCoin end to end and a status vector that names its disagreements by test. The emulation core is unchanged. Built on **v2.6.2 "Witness"** (2026-08-24) — rung 4 closes: blargg APU battery 11/11 on the co-simulation DUT, six defects no self-written gate could see, and a suite that had been asserting nothing for five minor releases. The emulation core is unchanged. Built on **v2.6.1 "Interleave"** (2026-08-24) — the DMC and its DMA cycle steal in the MiSTer co-simulation DUT, cycle-exact on the bus. The emulation core is unchanged. Built on **v2.6.0 "Assay"** (2026-08-24) — the triangle, the noise channel and the sweep unit **in the MiSTer co-simulation DUT** — and an audit of how much of the APU was fitted to the oracle rather than derived from documentation. The emulation core is unchanged. Built on **v2.5.9 "Overture"** (2026-08-24) — rung 4 opens: the two pulse channels, the frame counter, and four ROM defects the stimulus measurement found first. Built on **v2.5.8 "Blanking"** (2026-08-24) — VBlank, NMI and the PPUSTATUS race close rung 3 — and both fixes were deletions. Built on **v2.5.7 "Collimation"** (2026-08-24) — sprite rendering closes exact — the phase was wrong by two dots, and every window was compensating. Built on **v2.5.6 "Vestige"** (2026-08-23) — Sprite evaluation closes: all 59,993 overlapping cycles match, nine of nine behavioural mutants caught and two proved inert (announced as seven of eight at the cut), and the fix is a byte index that outlives the walk that set it. Built on **v2.5.5 "Raster"** (2026-08-23) — the first full frame, and three blind spots in the stimulus that fed it. Built on **v2.5.4 "Escapement"** (2026-08-23) — the background fetch pipeline, and an access two dots early that five gates could not see. Built on **v2.5.3 "Hysteresis"** (2026-08-23) — toggling rendering takes effect three dots after the write, and four instruments to prove it. Built on **v2.5.2 "Dormant"** (2026-08-23) — the 2C02 register file, and a gate that passed while testing nothing. Built on **v2.5.1 "Retrace"** (2026-08-23) — the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned. Built on **v2.5.0 "Rungwork"** (2026-08-23) — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** (2026-08-23) — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** (2026-08-23) — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** (2026-08-23) — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** (2026-08-22) — the core learns arithmetic. Built on **v2.4.5 "Compass"** (2026-08-22) — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** (2026-08-22) — the first real RTL -- the 6502's eight-cycle reset and the implied opcode group, matching the oracle on all seven CPU fields (29 +> **Current release: v3.1.0** (2026-10-08) — **"Bellwether"**, the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** (2026-09-29) — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** (2026-09-29) — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** (2026-09-29) — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. The mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). Built on **v2.9.2 "Candidate"** (2026-09-28) — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** (2026-09-27) — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** (2026-09-26) — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** (2026-09-26) — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** (2026-09-25) — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** (2026-09-25) — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** (2026-09-25) — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** (2026-09-25) — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** (2026-09-24) — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** (2026-09-24) — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** (2026-09-24) — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** (2026-09-23) — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** (2026-09-23) — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** (2026-09-23) — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** (2026-09-23) — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** (2026-09-20) — the access does not increment, it pulses the load already there. A `$2007` access during rendering pulses the rendering pipeline's existing load rather than computing a private increment, and resolving against that same value is what closed the CHR-during-rendering divergence in the MiSTer sibling: `chrram-live` went from **32,861 of 61,440 differing pixels**, red for eight releases, to "All 61440 pixels match", and `chrram-fetch` from 21,941 to 18. Eleven prior variants had been measured correctly and the conclusion drawn from them was wrong — they swept what the `$2007` arm computes and never what it computes it from. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction** and were re-run anyway. The bitstream is re-cut at fitter seed 4 (+0.421 ns setup / +0.112 ns hold) and **no hardware has run it**. The board session is now **v2.9.2** and the hardware-verified core **v3.0.0**, after three audit lines ([ADR 0041](adr/0041-hardware-release-is-v3.0.0.md)). Built on **v2.6.22 "Rigging"** — the instruments for the board, built before the board. **No hardware has run any bitstream**; the session with the board (then planned as "Shakedown"; since ADR 0041, v2.9.2) is the hardware half and this is the non-hardware half of it, cut separately so that name keeps meaning what it says. AccuracyCoin now reads back from hardware as BYTES rather than as a photograph — a mirror ROM copies its result vector into the battery-backed save window, with a byte budget that proves the patch moved nothing and a simulation control that proves it changed no answer. The catalog turned out to have **149 rows and 144 results**: five `Power On State` rows share upstream's omit-sentinel, and counting them made the headline a function of *when the run was sampled*. **144/144 and nestest 0-diff are unchanged** — the count stopped depending on the sampling instant. And the `46199ae4` re-sync turned out to have been half-done: three goldens described a ROM no longer in the tree, which no gate could see because `rom_sha256` was written into every manifest and compared to nothing. Built on **v2.6.21 "Steward"** — the board arrives, and the core is not ready for it. Battery saves exist for the first time; the CHR-during-rendering gate the retrospective audit asked for is added and is **RED at 32,861 of 61,440 pixels**, with the obvious fix measured insufficient at 715 recovered; two gates existed that nothing ran; and the deploy loop the runbook prescribed was built, which found two defects in its own spec. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.20 "Telltale"** — the counter had no reader, and two knobs turned out to be one decision. The FPGA core's OAM2 address counter was write-only for a release, so `$2004`'s post-fetch rest value was a hardcoded index 0; making it observable took the DUT to **AccuracyCoin 149 of 149** and the co-simulation ladder to **150 of 150, 0 failed**. Restoring the increment window v2.6.19 had narrowed then broke sprite rendering by six pixels, because the window and the deleted dot-257 latch are ONE decision with two self-consistent answers and only one of them is the hardware's. The shipped configuration restores the latch: window 256, the dot-257 freeze latch back and consumed by sprite fetch, and `$2004` reading the live counter. A CHR-RAM write gate was added. **v2.6.21 corrects this sentence: it did NOT close the coverage the audit named.** The audit named writes *while rendering is enabled*, and PROGRAM53 writes with `PPUMASK = 0` and renders afterwards. The real case is gated at v2.6.21 and is RED -- 32,861 of 61,440 pixels. The emulation core is unchanged, so **144/144 and nestest 0-diff hold by construction**. Built on **v2.6.19 "Accession"** — the DUT absorbs two releases of oracle behaviour, and the seed rule catches something for the first time. The FPGA core implements the OAM2 address counter it never had and stops acting on a `$2001` change that lands during dot 256 -- AccuracyCoin on the DUT **148 of 149**, the ladder **147 of 147**. The fitter pin is re-derived over ten seeds and stays at 3. (The contemporaneous claim that seed 1 stopped closing is withdrawn -- that sweep recompiled in place and was order-dependent; seed 1 closes, and seed 8 is the one that does not.) The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.18** (2026-09-12) — **"Errata"**, the recorded cause was wrong in three ways, and the last AccuracyCoin entry closes at 144/144 -- a `$2001` change during dot N must not act on dot N, at the dot-256 vertical increment and the dot-339 OAM2 reset. Built on **v2.6.17** (2026-09-11) — **"Terminus"**, the accuracy battery grows to 144 assigned tests and one of the new ones names the dots a write lands on -- this core is two dots early, measured twice on two independent `$2001` writes, and moving it is refuted by the gate this version wrote in advance, so the documented compensation stays and 143 of 144 ships. Built on **v2.6.16 "Interlock"** (2026-09-04) — the arbiter's numbers describe a stimulus, not the console -- and the console meets the CPU's deadline at zero margin, on every one of 240,303 requests, where the gate said it missed by four. Built on **v2.6.15 "Warrant"** (2026-09-04) — the claims v2.7.0 will make become checkable, and the instrument pays the oracle back. Built on **v2.6.14 "Docket"** (2026-09-03) — the submission checklist becomes auditable, and auditing it finds five boxes already true, two ticked on evidence that expired, and one that could never have been ticked honestly. v2.7.0 IS the submission, so the list in `to-dos/mister/contribution-checklist.md` decides whether the core is ready -- and it had 30 boxes, 16 unticked, and FOURTEEN OF THOSE SIXTEEN saying nothing at all about why. That ambiguity is the defect: an unticked box with no reason cannot be told apart from work outstanding, work blocked outside this repository, and WORK ALREADY DONE AND NEVER TICKED, and the third case occurred five times -- the provenance CI job, the SPDX sweep, the firewall statement, the AccuracyCoin vector and the preservation-value case were all true and all unticked, so the list reported the project as further from submission than it is by a fifth of its own length. ONE BOX COULD NEVER HAVE BEEN TICKED HONESTLY: it asked that `docs/provenance.md` state "that no NES core was ever opened", and that same document's section Do not self-certify forbids exactly that class of finished claim, so satisfying the box required writing the one sentence the provenance rules exist to prevent -- a wrong requirement rather than a missing tick, and only reading every item found it. RE-MEASURING THE TICKED HALF found two more, which is v2.6.9's lesson in a different document: `RustyNES.sdc` still said "there is exactly one core clock", true of v2.6.3 and false since v2.6.13, which added a 4x SDRAM clock and a phase-shifted pin clock that the shipped timing report names alongside it; and the `.qsf` entry said all 109 pin assignments come from `sys/sys.tcl`, where 109 is what that script supplies before stopping short of the I/O board, so a core must also source one of two 36-assignment variants -- 145 in total, from two scripts. THE ALARMING READING OF THE FIRST WAS CHECKED BEFORE IT WAS WRITTEN DOWN: it looks as though the framework's `set_clock_groups -exclusive` might be cutting the console-to-SDRAM crossing and leaving ADR 0039's safety argument unfalsifiable, and it is not -- exclusive cuts paths BETWEEN groups, all three outputs match one glob, and v2.6.13's own -24.769 ns measurement of that crossing is only observable because those paths are analysed. THE NAMING DIVERGENCE IS MEASURED RATHER THAN PARAPHRASED, from `Main_MiSTer/file_io.cpp` instead of the wiki: `get_display_name` searches for the literal underscore-two-zero and TRUNCATES THE DISPLAY NAME THERE, taking the rest as a datecode, and `DirentComp` groups by that truncated name -- so a version-named bitstream makes every release a SEPARATE CORE ENTRY, named for the version rather than the core, ordered alphabetically, which puts v2.6.9 after v2.6.13. The fix is one line and is NOT taken here, because version-naming is a maintainer decision with a stated rationale and reversing it is not an audit's call. THE TASK BOARD HAD THE SAME DEFECT AND ONE ROW WORSE: four delivered items never ticked, a hardware row still naming a version seven releases past, and an SDRAM row whose precondition -- "after a board exists" -- v2.6.13 simply did not follow, having accepted the controller against a behavioural model written from the datasheet instead; that is rung 7's own recorded lesson repeating one row below where it is written, since the blocker applied to hardware ACCEPTANCE rather than to building the thing. A CLAIM v2.6.13 SHIPPED IS RETRACTED: answering a review finding, I wrote that MMC1 was the sixth approved family and not yet implemented, and the cartridge decodes mapper 1 at three sites with a registered gate green since rung 7 opened -- asserted from memory inside a reply correcting somebody else's reading of the same line. The gate that keeps all this true asserts a SHAPE rather than a judgement and is demonstrated by five mutations, one of which proves the continuation-line folding load-bearing by producing nine false violations without it. The bitstream is BYTE-IDENTICAL to v2.6.13's, which is the point: the only sibling change is a comment, and an identical artifact demonstrates it. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.13 "Slack"** (2026-09-03) — the cartridge outgrows the die, and three consumers want the same bus. An SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge, all written from the AS4C32M16SB-7 datasheet revision 1.4 -- no third-party controller read, ADR 0037 applying. The budget the previous step worked to was a single figure read off the fetch structure and never measured; `nes_top`'s `CHR_LAT` sweep asks the console directly, and there are THREE answers: a background or sprite fetch tolerates 28 cycles and uses 17, the PPUDATA data port tolerates 8, and the CPU sampling at mc7 has 24 -- four cycles of every budget going to the console-domain crossing, which is not optional, because publishing a runtime modulo combinationally into an 11.64 ns domain costs -24.769 ns of setup. The PPUDATA port could never fit, half its budget being the crossing, so it leaves the shared bus entirely: `ppu2c02` gains `BUFFER_HANDSHAKE` and fills its read buffer through a port of its own on the arbiter, affordable because the CPU does not read that buffer until its next PPUDATA access. THAT FIX SHIPPED A DEFECT ONLY A BANKED CARTRIDGE COULD SEE -- the request carried the RAW fourteen bits the PPU presents, which is right by coincidence on NROM because the mapper's translation is the identity there, and reads BANK ZERO on everything else; `ppu-misc-2007-stress` passed off-die throughout while the two CNROM gates read a zero byte where the oracle reads one, one and two. The fix REMOVES the address rather than correcting it: `cart.sv` already publishes the translation for the fetch path, so the request carries none and there is one source of truth for where CHR lives instead of two that agree only on NROM. A DELETION WAS THEN REFUTED BY ONE CYCLE: with the address fixed, a control issuing at a flat +7 passed both gates, which read as the anti-contention deferral buying nothing, so it was removed -- and the deployed code issued at +6, and both gates failed again. The control and the code differed by a single cycle, which is the measurement of how thin the CPU's off-die deadline is and the concrete argument for scheduling the bus rather than arbitrating it. Two INFERRED LATCHES that Verilator cannot see: `ppu2c02` assigned two signals only under `BUFFER_HANDSHAKE`, so in the shipped on-die build the only assignment either reached was the reset branch, and a variable holding its previous value on every live path is a latch -- Quartus said so twice while the lint gate stayed green, and the comment beside them asserted the defect as a virtue ("outside BUFFER_HANDSHAKE neither signal ever moves", which is true and is exactly the condition). And a defect in the HARNESS: `USE_SDRAM` reaches Verilator as `-G` rather than as a file, so make ran ONE binary under both configurations' names and a log labelled on-die was the off-die build, reproducing the off-die failures exactly -- closed with a stamp-file prerequisite, demonstrated by mutation. THE OPEN ROW: accesses no longer auto-precharge, a hit costs 6 cycles against 10 for a miss, tRAS's 120 us MAXIMUM is respected by an early close in idle, and two defects came out of the rewrite -- re-entering idle with the request still asserted issued every access TWICE, and subtracting one from CAS the way the other waits are computed broke every read to all zeroes, CAS being "data appears at cycle N" rather than a command-to-command gap. Two MiSTer tickets close: `sdram_sz` is consumed VALIDITY BIT FIRST, so `absent` is deliberately not `!present` and a power-on all zeroes cannot read as "no board" (gated exhaustively over all 65,536 values), and `status_menumask` is computed rather than tied off, greying Reset whenever the console is already held in reset. OFF THE DIE THE CONSOLE PASSES 142 OF 142, every gate the on-die build passes, with better timing margin and 384 fewer M10K blocks -- and it still SHIPS ON the die, because an off-die core cannot run at all without the SDRAM add-on while rung 7's five mapper families fit on the die at 468 of 553 blocks. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.12 "Groundwork"** (2026-09-02) — the bitstream was an NROM-only console. Rung 7 landed five mapper families and 142 co-simulation gates verify them, and the layer that turns that RTL into a bitstream was never told: `rtl/emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND -- mapper 0 for EVERY cartridge, `prg_8k_count = 0` collapsing PRG to an 8 KiB window, and CHR forced to RAM. The declared 256 KiB PRG and 128 KiB CHR were implemented as 8 KiB each; connecting three wires takes block memory from 666,061 to 3,680,717 bits and timing still closes at all four corners. NOTHING COULD HAVE CAUGHT IT: simulation cannot, because `emu.sv` is not in the testbench file list and the harness drives those ports itself, so all 142 gates exercised a correctly-configured cartridge; and Quartus DID say so three times, in messages that cite an INSTANCE path rather than a file and are absent from the "0 errors, N warnings" tally, so the existing checker read 0 of 125. Two gates close that -- one fails on an unconnected pin of any module this repository declares, the other pins the warning SET rather than its count -- and both are demonstrated to fail by mutation. The `hps_io` tie-off audit that followed raised nine `T-MISTER-*` tickets, and annotating each with a blocker turned "none of these landed" into a measurement: not one is blocked on EFFORT, so the list is the rung-6 agenda rather than a backlog. `T-MISTER-SAVE` was attempted and refuted -- every save route terminates in `hps_io`, which no gate here instantiates. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.11 "Exposure"** (2026-09-02) — a picture is a gate the ladder did not have. All 141 co-simulation gates THEN IN THE SUITE were green (it ends this release at 142, the one it added) and TWO OF SIX commercial games rendered wrong -- a CHR-RAM write was taking the shared-pin composite address built for FETCHES instead of `v`, so the layout was right and the tiles were scrambled. The split is exactly CHR-ROM against CHR-RAM, which named the mechanism before any tracing, and a CONTROL says it is not v2.6.10's regression: the pre-M10K-fix RTL differs by the IDENTICAL 16,565 pixels, so the defect dates from the cartridge landing in v2.6.9. It was not UNREACHED -- the DUT asserts `chr_wr` 9,600 times in the Battletoads run -- it was UNCOMPARED: only THREE of the 141 gates compare a framebuffer, all three ship CHR-ROM, every other gate is CPU-side, and AccuracyCoin, the widest gate in the suite, is CHR-ROM too. The rung-7 gates' own comment says what they are for -- "these gates are about BANKING and nothing else" -- and it was accurate, and it was the whole coverage. Six commercial titles now render byte-identically to the oracle over all 61,440 pixels, published as a montage built by a script that REFUSES to publish a tile that differs from the oracle. The v2.6.10 bitstream carries the defect and a published version is immutable, so the corrected `.rbf` ships here. The same release finds EIGHT release leads describing v2.6.10 with v2.6.9's summary, and v2.6.9 gone from the lineage entirely, with `docs/STATUS.md` naming v2.6.10 under the codename "Abeyance" -- every existing check passing CORRECTLY, because they pin the version TOKEN and the token was right. Prose cannot be audited; an ORDERING can, so two gates are added and both are demonstrated to fail by mutation. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.10 "Inference"** (2026-09-01) — the cartridge meets the synthesiser. Five cartridge boards verified across 141 co-simulation gates had **never been through Quartus**, and Analysis & Synthesis refused the design: `chr` was written from TWO `always_ff` blocks, which cannot infer as one M10K, so 128 KB of CHR stayed in flip-flops -- **1,048,576 registers against roughly 166,000**. Simulation cannot ask this question: Verilator accepts both forms without complaint. It is v2.6.6's finding one layer out -- that release established that an M10K read is REGISTERED, this one that a correctly registered memory still will not infer with two writers. The fitter was also throttling itself under Auto Fit while the `.qsf` carried no optimisation assignments at all: at full effort **all six seeds close** where two had failed, so the effort settings move the whole distribution across zero and the seed only picks where in it you land -- and the project had been pinned to seed 4, the WORST of the six. Pinned at seed 3, +0.531 ns setup and +0.099 ns hold, byte-identical across two independent compiles. The bitstream v2.6.9 could not produce ships here. Built on **v2.6.9 "Abeyance"** (2026-08-31) — an exclusion hides improvement as well as regression, and both denied co-simulation streams close. The larger one was never the console: `apuconflict039` had been carried for seven releases as a declared diagnostic whose bus surface "carries nine divergences BY DESIGN", and the nine were a defect in the HARNESS -- on a cycle the CPU is held, the testbench built its record's bus data from a stale local rather than from the RTL's own latch. Taking it from the latch makes the stream IDENTICAL on all 357,361 overlapping cycles and all 88 checkpoints, and the local is now dead and deleted. The phrase "by design" is what stopped anyone re-checking it, because it reads as a property of the thing under test when it was a property of the instrument reading it. The other stream differs on EXACTLY ONE cycle, a documented and attributed OAM-corruption asymmetry -- and carrying that needed an instrument the suite did not have, because the PLANNED mechanism was refuted by its own mutation pass: an allowance by checkpoint index cannot work on a rolling hash, since one divergent cycle poisons every checkpoint after it, so allowing the first differing window simply moved the failure to the next one and allowing the rest is the all-or-nothing deny it was meant to replace. A per-cycle nine-field comparator with a scoped allowance costs ONE cycle of coverage instead of seventy-one checkpoints -- 357,360 of 357,361, against nothing at all before -- and it fails BOTH ways, so a DUT that improves cannot leave a stale allowance quietly hiding coverage; six mutations confirm it, including a cycle outside the compared window being REFUSED rather than allowed to match nothing. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction and were re-run anyway. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.8 "Arrears"** (2026-08-31) — the gates the previous release fixed and never widened. A deny list is an assertion about the THING UNDER TEST, so v2.6.7 changing both the DUT and the harness re-opened every exclusion -- and nothing re-measured one. FOUR of the six denied checkpoint streams were passing (`irqlat048`, `ppuvbl023`, `ppuvbl024`, `ppuvbl025`), and THREE of those were not run by the suite at all, so removing them from the deny list was INERT until they were also iterated. The nestest gate compared 265,000 cycles against a 5,062,688-cycle golden -- correct while caveat C6 was open, left behind the moment it closed -- and all 5,062,680 overlapping cycles now match, with the window DERIVED from the manifest. CAVEAT C4 CLOSES by demonstration: nestest raises `nmi_line` on 3,592 cycles and had no nine-field comparison at all, so wiring it makes an `o.nmi_line` mutation CAUGHT at checkpoint 28 of 1,237 while the bus gate passes the identical run. Suite 128 passed, 0 failed, 0 skipped; nine-field comparisons 51 -> 57. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no board is attached, confirmed by checking. Built on **v2.6.7 "Detent"** (2026-08-30) — the bitstream becomes a published release artifact and a one-cycle disagreement is pinned to the cycle it happens on. Every release from here ships a `.rbf` -- committed to the sibling's `releases/` and attached to the GitHub release on BOTH repositories -- reversing v2.6.6, which produced one and withheld it because no hardware had run it: the MiSTer distribution mechanism reads that path out of the REPOSITORY, so an empty `releases/` describes an undistributable core rather than a cautious one, and the caution moves from an absence into a disclosure naming what the ladder cannot reach by construction (the PPU gate compares the pre-palette index and the APU gate per-channel integer levels, so the palette, the video timing constants, the audio absolute level and its band-limiting all sit downstream of every gate). The build is REPRODUCIBLE and that is now measured rather than argued -- a from-scratch compile and an incremental one produce a byte-identical bitstream -- which is also how v2.6.6's published slack figures came to be WITHDRAWN: no corner of a clean rebuild reproduces them, the innocent explanation (a different timing corner) was checked first and refuted, and the correct pair is +0.108 ns setup and +0.042 ns hold at the binding corner. THE RELEASE GATE WAS READING THE WRONG CORNER -- Slow 100C is not the binding one on this design, so a bitstream failing at Slow -40C would have passed while the gate reported three times the real margin -- and the checker that reads it was wrong twice before mutation found both: it first extracted ZERO rows from both summary tables and reported that as "no negative slack", then, once fixed, reported FOURTEEN clocks from a report emptied of its data, having run past the closing rule into the next tables. Caveat C2 splits in two. The first residual was a TRACE OBSERVATION POINT -- the harness built its record after eight of a CPU cycle's twelve master clocks while the oracle reads at end-of-cycle, and the frame-counter interrupt asserts on the final edge -- and closing it took checkpoint comparisons from 3 to 11 and failures at one checkpoint from 30 to 3. The second is REAL, and the FIRST fix for it was REFUTED in a way that found the right one: moving all four effects of the write one cycle later to match the oracle drops blargg from 11/11 to 4 of 11, one of the seven being the ROM written to probe exactly that timing. Read as a measurement that PROVES the sequencer's maturation is correctly placed, which leaves only the other effects the same write schedules -- so separating ONLY the interrupt clear lands it at write+3, the documented cycle, while the frame counter's zeroing stays put. Checkpoint streams go from 11 identical to 52 of 58 and the suite from 87 to 122, with blargg still 11/11 and the bus still matching on all 2,680,239 overlapping cycles. The checkpoint gate is registered over 51 comparisons and STATES ITS BLIND SPOT: not one gated golden ever raises an NMI, so it cannot catch an nmi_line defect, and the attempt to close that hole found a FOURTH divergence cluster that three goldens had been hiding inside a "26 skipped" tally line. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.6 "Chassis"** (2026-08-29) — the console becomes a MiSTer core -- `sys/` vendored byte-identical against `Template_MiSTer@3ea1134c` (57 files, 0 content differences), a top level, a clock, a palette, video sync and an audio mixer, compiled by Quartus 17.0.2 into a Cyclone V bitstream with 0 errors, timing CLOSED, and a warning count taken from 111 to THREE -- all three inside the vendored framework or Quartus's own megafunction, none of them citing this project's RTL (worst setup +0.086 ns and worst hold +0.096 ns at the binding corners, seed 3 -- v2.6.7 also withdraws the +0.363/+0.245 v2.6.6 published, which a clean rebuild of that configuration reproduces at no corner, TNS 0.000 on every clock; the console's own clock +13.514 ns at an Fmax of 30.26 MHz against the 21.477272 MHz it needs). The emulation core is unchanged, so the co-simulation suite is an ACCEPTANCE CRITERION rather than a formality -- 87 passed, 0 failed -- and it earned that immediately, because the cartridge memories had to be rewritten: an M10K read is REGISTERED, so 40 KiB of asynchronously-read cartridge was 393,216 registers against roughly 166,000 available, under a comment claiming it inferred block RAM from the source style alone, and the README had stated the correct rule since v2.4.3. Two defects were found only by asking whether the outputs would work on real hardware: the audio would have been a full-scale DC rail, because the mixer output is unipolar with silence at zero and the framework maps unsigned silence to -32768, and two OSD scanline options did nothing because VGA_SL was tied to zero. And a convention enforced by a glob has no error message: sys_top.sdc groups the core clock by matching the hierarchical name pattern *|pll|pll_inst|altera_pll_i|*, so a differently-named PLL matched no group at all and every crossing to the framework audio, HDMI and HPS domains was analysed as synchronous -- -13.901 ns of slack and -422,601 ns of TNS on a design whose Fmax was already above requirement, with the compile succeeding and the Assembler reporting 0 errors and 0 warnings throughout. Built on **v2.6.5 "Muster"** (2026-08-29) — rung 5 closes — the AccuracyCoin status vector is identical entry for entry across all 146 entries, with 146 of 146 executed on both sides and none NotRun, where the same gate read 5 of 146 at the version's start. A muster is a roll call where every name is called AND answered, which is the two-clause acceptance exactly. Five PPU defects close the last six differing entries and four were invisible to every gate that existed when the version opened: the background shift registers' RELOAD and their shift clock need SEPARATE gates (with one shared gate the serial-in test was not merely failing but ARITHMETICALLY UNREACHABLE, since reload dots are absolute and the reload discards the low seven bits, so a serial-in one can never reach bit 7 on any alignment — and modelling both structures reproduces BOTH measured shifter values); the sprite X counters are NOT gated on rendering, which AccuracyCoin states outright and the ROM that states it passes either way, because it expects no hit at X=254 and a sprite shoved 18 dots right is also off the line; the PPUADDR second-write v-copy is DELAYED, as the wiki says inside the write sequence itself, swept 1 to 4 dots against a control at 8 and 12 that fails; and the pre-render line CLEARS secondary OAM, without which scanline 0 draws what scanline 239 left — no sprite can ever render on scanline 0, because OAM Y is one less than the display row, and a sprite-0 probe over the full 134 M-cycle battery found 24 hits with four of them there; and the octal latch holding across the read dot, which is verified by exactly ONE gate and was unverifiable until the v-copy delay landed, the two composing the hybrid address together and neither producing it alone. A DIAGNOSIS IS RETRACTED: the residual was read as a two-dot CPU/PPU alignment error from comparing dot spans across two instruments, and at the committed alignment the two consoles execute identical pc, bus_addr and bus_access for 1,695,131 cycles while a two-dot shift moves the first fork back to 593,228 and takes the differing share from 5.13% to 66.80%. The oracle changes on the default path, so AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff are VERIFIED, not asserted. Built on **v2.6.4 "Rubric"** (2026-08-26) — OAM DMA lands and all nine AccuracyCoin disagreements close, every rule that closed the last three stated by the test ROM and by neither nesdev page — and then the gate that certified them is measured to cover 88 of 146 entries. The emulation core is unchanged. Built on **v2.6.3 "Mainspring"** (2026-08-25) — the DUT runs on one master clock, and four enables that were never enabling — plus AccuracyCoin end to end and a status vector that names its disagreements by test. The emulation core is unchanged. Built on **v2.6.2 "Witness"** (2026-08-24) — rung 4 closes: blargg APU battery 11/11 on the co-simulation DUT, six defects no self-written gate could see, and a suite that had been asserting nothing for five minor releases. The emulation core is unchanged. Built on **v2.6.1 "Interleave"** (2026-08-24) — the DMC and its DMA cycle steal in the MiSTer co-simulation DUT, cycle-exact on the bus. The emulation core is unchanged. Built on **v2.6.0 "Assay"** (2026-08-24) — the triangle, the noise channel and the sweep unit **in the MiSTer co-simulation DUT** — and an audit of how much of the APU was fitted to the oracle rather than derived from documentation. The emulation core is unchanged. Built on **v2.5.9 "Overture"** (2026-08-24) — rung 4 opens: the two pulse channels, the frame counter, and four ROM defects the stimulus measurement found first. Built on **v2.5.8 "Blanking"** (2026-08-24) — VBlank, NMI and the PPUSTATUS race close rung 3 — and both fixes were deletions. Built on **v2.5.7 "Collimation"** (2026-08-24) — sprite rendering closes exact — the phase was wrong by two dots, and every window was compensating. Built on **v2.5.6 "Vestige"** (2026-08-23) — Sprite evaluation closes: all 59,993 overlapping cycles match, nine of nine behavioural mutants caught and two proved inert (announced as seven of eight at the cut), and the fix is a byte index that outlives the walk that set it. Built on **v2.5.5 "Raster"** (2026-08-23) — the first full frame, and three blind spots in the stimulus that fed it. Built on **v2.5.4 "Escapement"** (2026-08-23) — the background fetch pipeline, and an access two dots early that five gates could not see. Built on **v2.5.3 "Hysteresis"** (2026-08-23) — toggling rendering takes effect three dots after the write, and four instruments to prove it. Built on **v2.5.2 "Dormant"** (2026-08-23) — the 2C02 register file, and a gate that passed while testing nothing. Built on **v2.5.1 "Retrace"** (2026-08-23) — the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned. Built on **v2.5.0 "Rungwork"** (2026-08-23) — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** (2026-08-23) — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** (2026-08-23) — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** (2026-08-23) — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** (2026-08-22) — the core learns arithmetic. Built on **v2.4.5 "Compass"** (2026-08-22) — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** (2026-08-22) — the first real RTL -- the 6502's eight-cycle reset and the implied opcode group, matching the oracle on all seven CPU fields (29 > records, `RustyNES_MiSTer@7f092bd`). The oracle settled a question our own > prose could not: reset is EIGHT cycles, and `docs/cpu-6502.md` said both > seven and eight. The emulation core is untouched. @@ -1883,7 +1883,7 @@ without panicking (no `$6000` status protocol). | `vrc24test` | — | — | — | — | **Skipped (Track B1)**: link rot. AWJ's original forum attachment (id=10017 on forums.nesdev.org/viewtopic.php?p=203716) is auth-walled; the deletion is documented at archive.nes.science. No GitHub mirror found. | | `AccuracyCoin` | 1 | 1 | — | — | 100thCoin / Chris Siebert single-NROM accuracy battery (MIT license, 151 catalog rows across 22 suites: 146 scored tests + 5 visual-only `Power On State` tests sharing `$03FF`; the v2.0.1 upstream re-sync grew the catalog from 144 to 146 rows / 139 to 141 assigned tests, adding the PPU "ALE + Read" and "Hybrid Addresses" tests, and the **2026-09 re-sync** to upstream `69c8860` grew it again to 149 rows / 144 assigned across 22 suites — two new pages, `Advanced Background Evaluation` and `Advanced Sprite Evaluation`, which re-home eleven existing PPU tests that had outgrown `PPU Misc.`, plus three genuinely new tests, and the **v3.1.0 re-sync** to upstream `f5f41dc2` grew it to 151 rows / 146 scored with two new `CPU Behavior 2` tests, `DMA Landing on Write` and `DMC Reload Timing`). Interactive (D-Pad menu); the harness presses `START` to "run all tests on the ROM" then takes two parallel measurements. **(1) Framebuffer decoder** reads the 10×16 on-screen result grid by exact-pixel colour (5-colour palette: `#64A0FF` = pass, `#4F1000` = fail, `#DC834C` = partial-pass, `#4C4C4C` = no-test / not-run, `#FFFFFF` = border); this is the legacy path and has a known grid-stride bug that under-samples by ~31 cells. **(2) RAM-direct decoder** reads each test's result byte from its fixed CPU-RAM address (catalogued from upstream `AccuracyCoin.asm` in `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` and `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` — 151 `(suite, name, addr)` triples, regenerated by `scripts/accuracycoin-build/extract_catalog.py`) and decodes per-test pass/fail/error-code names + per-suite breakdowns. This is the authoritative path. **Current measured pass rate (RAM-direct): 100.00% (146/146, upstream `f5f41dc2`, v3.1.0)** on the default build. The re-sync's ROM found two defects, fixed in the same change: a DMC load DMA refused by a write took three cycles instead of four (`DMA Landing on Write`), and sprite evaluation took its start from OAMADDR at dot 0 instead of dot 65 and dropped a misaligned OAMADDR after an in-range X (`Misaligned OAM behavior`, which the pre-`f5f41dc2` ROM had recorded as a pass because its fail path returned into the test body without popping the return address). Before that, the two upstream PPU tests "ALE + Read" and "Hybrid Addresses" (briefly the only gaps at 139/141 after the v2.0.1 catalog re-sync) were **closed in v2.0.3** by promoting the 2-cycle-ALE PPU fetch model to the unconditional default. The `90.65%`, `84.17%` and the trajectory figures below are historical engine-lineage milestones (the pre-promotion v1.0.0-rc2 / Session-26 era), retained as history. Historical trajectory: `64.03%` (post-D2 baseline) → `67.63%` (post-D3, 7 6502 bus-pattern fixes) → `69.06%` (post-Phase-3 OAM DMA parity fix, +1 strict test flipped) → `69.78%` (post-FSM-fix recovery, +1 sprite-related sub-test flipped as a side-benefit of the `crates/rustynes-ppu/src/ppu.rs` dot-64 reset removal) → `76.98%` (post-Cascade-B DMC DMA scheduler, commit `9b0c81c` — closes all 8 tests in the `APU Registers and DMA tests` suite + 3 net elsewhere as side-benefits; +11 tests flipped) → `78.42%` (post-Cascade-A OAMADDR-during-rendering reset, commit `f29f7ca` — hardware-accurate per nesdev: OAMADDR is reset to 0 during dots 257-320 of every rendered scanline; +2 tests flipped — Sprite overflow behavior PASSES, Sprite 0 Hit advances from error 1 → error 13) → `79.14%` (post-session-7 OAMADDR-walks-during-eval + $4-aligned `$2004` write, commit `c230489` — closes `Address $2004 behavior` with code 16; +1 net flip) → `79.86%` (post-session-7 RMW ABS,X/Y unfixed-address dummy read, commit `32d5b18` — 18 RMW opcodes get the canonical cycle-4 unfixed-address dummy; flips `APU Tests :: Controller Clocking` and advances `Implied Dummy Reads` 2→3 + `Frame Counter IRQ` 6→7 via the SLO $4015,X bracket; +1 net flip) → `82.73%` (post-session-8 BG-pipeline cycle-9 reload + post-emit shift, commit `086ce4d` — fixes the long-standing 1-column BG pixel off-by-one identified in `docs/audit/cascade-a-investigation-2026-05-19.md`; flips `Sprite 0 Hit behavior` + `Sprite overflow behavior` + `Suddenly Resize Sprite` + `$2007 read w/ rendering`; +4 net flips, +2.87pp) → `83.45%` (post-session-24 Controller Strobing M2-low-defer write, Session-24 Phase 3 — deferred `$4016` commit buffer on `LockstepBus` mirrors Mesen2's `NesControlManager::ProcessWrites`; flips `APU Tests :: Controller Strobing` from `[error 4]` to PASS; +1 net flip) → **`84.17%` (post-session-26 Sprint 2 iter 5 Frame-Counter-IRQ split, 2026-05-23 — separates `FrameCounter::irq_flag` ($4015 bit 6 visibility) from `FrameCounter::irq_line_active` (CPU IRQ source driver) so Tests I/J/K/L/M/N/O all PASS without spuriously asserting the CPU IRQ line on inhibited frame-counter cycles; flips `APU Tests :: Frame Counter IRQ` from `[error 19]` to PASS; +1 net flip)**. Session-26 Sprint 2 iter 4 (APU Register Activation OAM-DMA chip-select gate) advanced the same suite's APU Register Activation entry internally from `[error 4]` to `[error 6]` but did not flip the catalog-headline metric. The previous `75.93%` headline reflected the framebuffer decoder's stride bug, not real accuracy. Strict floor in CI is **60%** — see `crates/rustynes-test-harness/tests/accuracycoin.rs::MIN_PASS_RATE`. the v0.9.x 80% target and the v1.0.0 90% gate were both cleared, and the default build measured **100.00% (144/144)** until the v3.1.0 re-sync (146/146 since) — the master-clock core is the default, the former C1 + sub-cycle residuals are closed, and the two v2.0.1 PPU tests were closed in v2.0.3 (see "Accuracy residuals" below). **No open AccuracyCoin gap, as of v2.6.18.** The 2026-09 re-sync opened two, both in the new `Advanced Sprite Evaluation` page. `Misaligned OAM2 Address` ($0495) closed in v2.6.17 by modelling `OAM2Address` as a live counter through sprite fetch. `Frozen OAM2 Increment` ($0493) closed in **v2.6.18**, and the long diagnosis this row used to carry is **RETRACTED in all three of its claims** — it said a `$2001` enable on dot 256 took effect a dot early (all four writes the ROM names are in force from the START of the dot it names), it blamed test 2 or 3 (the failure was test **4**, the false-positive guard, and the ROM omits an `INC 144 assigned tests across two new pages, and `46199ae4` changes the catalog not at all; the last of them, diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index f9ac97e4e..44048b50d 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -9,7 +9,7 @@ this ledger is the approximation map. The remediation line is **v2.1.0 "Fathom"** (accuracy work, shipping ahead of the v2.2.0 mobile store launch). **Headline:** every suite on the oracle path is green, with **no named -exception** — AccuracyCoin **144/144 (100.00%, RAM decoder)** as of v2.6.18, +exception** — AccuracyCoin **146/146 (100.00%, RAM decoder)** at upstream `f5f41dc2` since v3.1.0, whose re-sync showed that the 144/144 reported from v2.6.18 to v3.0.1 was overstated, because the older ROM's `Misaligned OAM behavior` fail path fell through to a pass and hid a real failure. Before that: **144/144** as of v2.6.18, which closed `Advanced Sprite Evaluation :: Frozen OAM2 Increment`, the last entry the 2026-09 upstream re-sync left open. `KNOWN_FAILING` is now empty; it is retained, because it fails BOTH ways and is what caught this closure. diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md index 7793b4ede..1be159404 100644 --- a/docs/dev/TESTING.md +++ b/docs/dev/TESTING.md @@ -19,7 +19,7 @@ RustyNES employs a comprehensive testing strategy combining unit tests, integrat ### Testing Goals -- **AccuracyCoin 144/144 (100.00%)** since v2.6.18, on the catalog re-synced at v2.6.17 (144 assigned tests). It read 139/139 before the v2.0.1 re-sync grew it to 141, and 141/141 from v2.0.3 +- **AccuracyCoin 146/146 (100.00%)** since v3.1.0, on the catalog re-synced to upstream `f5f41dc2` (146 scored tests); the 144/144 reported from v2.6.18 to v3.0.1 was overstated, because the older ROM's `Misaligned OAM behavior` fail path fell through to a pass and hid a real failure. It read 144/144 from v2.6.18, on the catalog re-synced at v2.6.17 (144 assigned tests). It read 139/139 before the v2.0.1 re-sync grew it to 141, and 141/141 from v2.0.3 - **Unit test coverage** for all components - **Integration tests** for component interactions - **Regression tests** for mapper edge cases (191 mapper families) diff --git a/docs/frontend.md b/docs/frontend.md index 8e749c7bb..ecda55cfa 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -1235,8 +1235,9 @@ builds stay byte-identical and AccuracyCoin holds 139/141 (the two newest upstre Frontend-only, additive, English-by-default — with the default locale every label is byte-identical to v1.6.0. (At v1.7.0 AccuracyCoin held 139/141; the two -PPU gaps closed at v2.0.3, and the re-synced catalog has held 144/144 since -v2.6.18.) See +PPU gaps closed at v2.0.3, the re-synced catalog read 144/144 from v2.6.18, +and 146/146 from v3.1.0, whose re-sync showed the 144/144 had hidden a +masked failure.) See ADR 0023 for the rationale (why a hand-rolled catalog over Fluent/ICU/`rust-i18n` and the wasm size budget). diff --git a/docs/user-guide/compatibility.md b/docs/user-guide/compatibility.md index 4f4631b5f..6a22ef331 100644 --- a/docs/user-guide/compatibility.md +++ b/docs/user-guide/compatibility.md @@ -93,8 +93,9 @@ for how the region is determined. ## Accuracy RustyNES clears the headline accuracy bar: 100thCoin's **AccuracyCoin** suite -at **100.00% (144/144)** (the "ALE + Read" and "Hybrid Addresses" gaps closed in -v2.0.3, the last failing entry in v2.6.18), **nestest** with zero +at **100.00% (146/146)** on upstream `f5f41dc2` since v3.1.0 (the 144/144 +reported before it hid a masked `Misaligned OAM behavior` failure; the "ALE + +Read" and "Hybrid Addresses" gaps closed in v2.0.3), **nestest** with zero golden-log diff over 8,991 instructions, and the entire blargg `instr_test_v5`, `instr_misc`, `instr_timing`, `cpu_timing_test6`, `cpu_interrupts_v2`, `ppu_open_bus`, `ppu_vbl_nmi`, `apu_test`, diff --git a/ios/project.yml b/ios/project.yml index cd5f3c977..217c22237 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -45,7 +45,7 @@ settings: # 2.0.8 through v2.9.9, since nothing moved it), and moved by # `scripts/release-automation/bump_release.py` from now on, starting with # the 3.0.0 cut. - MARKETING_VERSION: "3.0.1" + MARKETING_VERSION: "3.1.0" # The build (not marketing) number. Each TestFlight upload needs a UNIQUE one; # the fastlane `beta` lane overrides this with the CI run number at build time # (xcargs CURRENT_PROJECT_VERSION), so this checked-in "1" is only the local / diff --git a/tests/roms/LICENSES.md b/tests/roms/LICENSES.md index b216ee82f..43215246a 100644 --- a/tests/roms/LICENSES.md +++ b/tests/roms/LICENSES.md @@ -188,7 +188,7 @@ AccuracyCoin is a single-NROM-cartridge battery of NES accuracy tests. The ROM i plus hex error codes) with no `$6000` status protocol. The integration test in `crates/rustynes-test-harness/tests/accuracycoin.rs` decodes the per-test result state from RAM and asserts the measured pass rate, which RustyNES holds at -**144/144 (100.00%)** (see `docs/STATUS.md`). +**146/146 (100.00%)** at upstream `f5f41dc2` (see `docs/STATUS.md`). ## "full palette" ROMs diff --git a/to-dos/README.md b/to-dos/README.md index 4a1e7cbf2..58ebe8dc2 100644 --- a/to-dos/README.md +++ b/to-dos/README.md @@ -11,8 +11,8 @@ lines named v1.8.8 until v2.7.6). This directory holds the phase-and-sprint development history that produced RustyNES v1.0.0 (the cycle-accurate production core) and the long line of feature/platform releases built on top of it. The phases below are -**delivered** — RustyNES ships at v3.0.0 with a cycle-accurate core -(AccuracyCoin 144/144), **191 mapper families**, FDS, Vs./PC10, rollback netplay, +**delivered** — RustyNES ships at v3.1.0 with a cycle-accurate core +(AccuracyCoin 146/146), **191 mapper families**, FDS, Vs./PC10, rollback netplay, RetroAchievements, TAS movie tooling, the performance + desktop-UX shell, the full v1.1.0 → v1.7.x feature set (Lua scripting, visual filters, the studio / TAS-tooling / debugger-depth suite, the writable/programmable "Forge" tools, diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 19af3c4d7..0b9369205 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -57,7 +57,7 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. ## Status -- **Current release:** **RustyNES v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** +- **Current release:** **RustyNES v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** - **The line after v3.0.0 (drafted 2026-10-07):** [`plans/v3.1-to-v4.0-line-plan.md`](plans/v3.1-to-v4.0-line-plan.md) indexes v3.1.0 → v4.0.0, with one plan file per release and the maintainer's 29 decisions (D1-D29) tabled there. In order (D29 moved the hardware release to the end of v3.9.x the same day): - v3.0.1 "Mortar" (in progress); - v3.1.0: options, emphasis, the AccuracyCoin re-sync; @@ -73,7 +73,7 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. - **Programme after v2.4.0 — the v2.4.1 → v2.5.0 "Fabric" line, and the v2.6–v2.9 programme behind it** *(since 2026-09-22 the programme ends at v3.0.0; ADR 0041)*. An **independently-written NES core in SystemVerilog for MiSTer FPGA and the Retro Remake SuperStation One, verified against RustyNES as an oracle.** Not a port, and it cannot be one: a MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream. The reference firewall therefore extends to HDL — `NES_MiSTer` and `fpganes` `rtl/` are **strict black boxes**, instantiable as opaque modules to compare *outputs*, never readable as source. **v2.5.0 is scoped to "the 6502 rung closes"** — the co-simulation harness plus a cycle-exact 6502, gated, **as planned**, on nestest 0-diff and per-cycle bus equality — of which **per-cycle bus equality was achieved and nestest 0-diff was not**: it stops at a `$2002` read where *both sides address it* and only the data differs, because the DUT has no PPU. That and the 5 M-cycle window are **reclassified as rung-3 acceptance criteria** rather than carried as v2.5.0 debt — because the arithmetic does not support more: a from-scratch cycle-accurate NES core is **7–13 months FTE** against a two-to-four-week window at demonstrated cadence. PPU, APU and MiSTer integration are **v2.6–v2.9**; stating that now is better than discovering it at v2.4.6. The design is **replay, not lockstep** (the determinism contract makes a pre-recorded trace exactly the trace a lockstep run would produce, and `Nes` has no per-cycle step to lockstep *with*), **no DPI-C** (it would put `` `ifdef SIMULATION `` guards into RTL that must also pass Quartus — the exact construct that lets a simulated netlist drift from the synthesised one), and **hash first, capture on divergence** (a 4200-frame AccuracyCoin run is ~7.5 GB of per-cycle CSV; 4096-cycle hash checkpoints are ~480 KB). **Two risks are accepted in writing:** the core may be **declined as a duplicate** — `NES_MiSTer` already scores 121/125 on AccuracyCoin, and *real Famicom AV hardware also scores ~121/125*, so there is no published accuracy headroom; and **the oracle can be wrong**, since 141/141 is not "matches silicon", so every rung is labelled by whether it has an **independent** oracle. Retro Remake is a planned fallback home, not a contingency. See ADR 0037, `docs/mister.md`, and [`plans/v2.5.0-fabric-plan.md`](plans/v2.5.0-fabric-plan.md). - **Programme after v2.5.0 — the v2.5.1 → v2.7.0 line: the rest of the console, and a contributable package.** The Fabric line is delivered and the 6502 rung is closed; this line builds the PPU, APU, mappers and MiSTer integration, and takes the core to a state worth submitting to MiSTer-devel. **Maintainer decisions, 2026-08-23:** hardware is **both boards eventually** — a DE10-Nano **plus the SDRAM add-on** (mandatory: the NES reads cartridge ROM directly and the onboard DDR3 is too slow) and a SuperStation One (128 MB integrated), with **one `.rbf` booting both** turning "SS1 runs MiSTer cores unmodified" from an inherited claim into a measured one; mappers are **the top six** — NROM, MMC1, UxROM, CNROM, MMC3, AxROM, ~90% of the licensed library by title count, explicitly **not** FDS, expansion audio, or the remaining ~168 families; and v2.7.0 is **scoped to what genuinely fits**, with the arithmetic stated up front (**rung 3 8–16 wk · rung 4 4–8 wk · rung 5 2–4 wk + a 4–12 wk tail · rung 6 2–4 wk · rung 7 4–8 wk = 20–40 weeks FTE** before the AccuracyCoin tail, across twenty release slots — **milestones, not dates**). **Rung 6 comes before rung 7 deliberately**: NROM at 327 Kb fits on-chip, so hardware bring-up needs no memory controller, and getting a board in the loop before writing the SDRAM controller de-risks the second largest technical item. Two v2.5.0 gates — **nestest 0-diff and the 5 M-cycle window** — are not carried as debt but reclassified as **rung-3 acceptance criteria**: both stop at a `$2002` read where *both sides address it* and only the data differs, because the DUT has no PPU. The contribution requirements were **fetched from the MiSTer-devel wiki rather than recalled**, and one line of it is the whole case for this programme: on AI-generated code the project asks for *"a minimum reasonable bar for readability and… evidence of quality and accuracy testing"* — the co-simulation apparatus **is** that evidence, and no incumbent core can show its equivalent. See [`plans/v2.7.0-mister-core-plan.md`](plans/v2.7.0-mister-core-plan.md), [`mister/`](mister/), and the four dated research files in `ref-docs/`. *Superseded 2026-09-22 by ADR 0041: the hardware-verified core and the contribution package are v3.0.0.* - **Historical detail — v2.2.4** (2026-07-24) — a **libretro / RetroArch distribution** cut whose purpose is that the RustyNES core **builds and installs cleanly through the Libretro buildbot** () for in-RetroArch use. **Zero emulation-core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical to v2.2.3, so **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff, by construction. The work is a libretro-completeness audit + metadata correction: the core is confirmed to inherit every v2.2.3 change automatically (the fast-dot-path default, the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema handled transparently by the dynamic `snapshot_core_into` sizing, the `Mapper::mix_audio` i32 widening, the Zapper model, and the `mNNN_` mapper rename), and both buildbot cross-ABIs the GitHub gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — build clean. `rustynes_libretro.info` (the metadata RetroArch's core downloader reads) is corrected: **`disk_control` `false` → `true`** (the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed, but was advertised as absent — the real fix), `display_version` `v1.0.0` → `v2.2.4`, and the mapper count `168` → `172`. Also: the reviewer-tooling standardization onto the shared Antigravity template rides along (`scripts/agy-review.sh` + workflow). Documented libretro follow-up: **core options** (region / overscan / palette / accuracy toggles) remain unexposed (`core_options = "false"` is accurate, not stale) — a deliberate future enhancement, not a v2.2.4 gap. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.2.4]` + `docs/libretro/`. -- **Release line since v2.1.0:** the v2.1.x **"Fathom"** accuracy line (v2.1.0 → v2.1.10) → **v2.2.0 "Capstone"** (the milestone cut closing the "deepen the existing project" run) → **v2.2.1** (housekeeping) → **v2.2.2 "Conduit"** (build / distribution / CI-integrity) → **v2.2.3 "Datum"** (performance appraisal + the last two Holy Mapperel residuals closed) → **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut) → **v2.2.5 "Colophon"** → **v2.2.6 "Almanac"** → **v2.2.7 "Timbre II"** → **v2.2.8 "Aperture II"** → **v2.2.9 "Studio II"** → **v2.3.0 "Datum II"** → **v2.3.1 "Plumb Line"** → **v2.3.2 "Lucid"** → **v2.3.3 "Cadence"** → **v2.3.4 "Ledger"** → **v2.3.5 "Manifest"** → **v2.3.6 "Sounding"** → **v2.3.7 "Overtone"** → **v2.3.8 "Parallax"** → **v2.3.9 "Crucible"** → the **v2.4.x "Fabric"** co-simulation line (**v2.4.1 "Fabric"** → **v2.4.2 "Cairn"** → **v2.4.3 "Touchstone"** → **v2.4.4 "Ignition"** → **v2.4.5 "Compass"** → **v2.4.6 "Abacus"** → **v2.4.7 "Keystone"** → **v2.4.8 "Palimpsest"** → **v2.4.9 "Plumbline II"** → **v2.5.0 "Rungwork"** → **v2.5.1 "Retrace"** → **v2.5.2 "Dormant"** → **v2.5.3 "Hysteresis"** → **v2.5.4 "Escapement"** → **v2.5.5 "Raster"** → **v2.5.6 "Vestige"** → **v2.5.7 "Collimation"** → **v2.5.8 "Blanking"** → **v2.5.9 "Overture"** → **v2.6.0 "Assay"** → **v2.6.1 "Interleave"** → **v2.6.2 "Witness"** → **v2.6.3 "Mainspring"** → **v2.6.4 "Rubric"** → **v2.6.5 "Muster"** → **v2.6.6 "Chassis"** → **v2.6.7 "Detent"** (the bitstream becomes a published, reproducible release artifact and a one-cycle disagreement is attributed rather than fitted away) → **v2.6.8 "Arrears"** (the gates the previous release fixed and never widened: four of six denied checkpoint streams had been passing unnoticed, three of them not run by the suite at all, and nestest widens 19x and gains the nine-field comparison that closes the gate's stated `nmi_line` blind spot) → **v2.6.9 "Abeyance"** (an exclusion hides improvement as well as regression -- both denied co-simulation streams close, and the larger one was a defect in the HARNESS rather than the console, carried for seven releases behind the phrase "by design") → **v2.6.10 "Inference"** (the cartridge meets the synthesiser, and simulation could not have asked the question -- rung 7's five boards had never been through Quartus, and `chr` written from two separate `always_ff` blocks could not infer as an M10K, so 128 KB stayed in flip-flops: 1,048,576 registers against roughly 166,000. The fix is behaviourally invisible -- 141 gates unchanged -- and the release carries the bitstream the previous one could not produce) → **v2.6.11 "Exposure"** (a picture is a gate the ladder did not have -- all 141 co-simulation gates green and two of six commercial games rendering wrong, because only THREE of those gates compare a framebuffer and all three ship CHR-ROM, so a CHR-RAM write taking the shared-pin composite address built for fetches was reached 9,600 times and compared never)) -> **v2.6.12 "Groundwork"** (the bitstream was an NROM-only console -- `emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND and the fitted cartridge was mapper 0 with 8 KiB of PRG and 8 KiB of CHR against a declared 256 and 128; no gate could see it because `emu.sv` is not in the testbench file list, so all 142 gates exercised a correctly-configured cartridge, and Quartus said so three times in messages that cite an instance path rather than a file) -> **v2.6.13 "Slack"** (the cartridge outgrows the die -- an SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge written from the AS4C32M16SB-7 datasheet, and three consumers measured to three different budgets rather than the one figure the previous step assumed; the PPUDATA port leaves the shared bus through a handshake and that fix shipped a defect only a banked cartridge could see, the request carrying the raw PPU address where the mapper's translation was needed, so every banked-CHR board read bank zero; off the die the console passes 142 of 142 and it still ships on the die, because an off-die core cannot run without the add-on) -> **v2.6.14 "Docket"** (the submission checklist becomes auditable -- 30 boxes, 16 unticked and fourteen of those saying nothing about why, so an unticked box could not be told apart from work outstanding, work blocked elsewhere, and work already done and never ticked; the third case occurred five times. One box asked `docs/provenance.md` to state that no NES core was ever opened, which that document's own Do-not-self-certify section forbids, so it could only have been ticked by writing the sentence the provenance rules exist to prevent. Re-measuring the ticked half found two claims that had expired, the task board four delivered items never ticked and an SDRAM precondition the previous release did not follow, and its claim that MMC1 was unimplemented is retracted) → **v2.6.15 "Warrant"** (the claims the submission will make become checkable, and the instrument pays the oracle back — the `.rbf` name this core shipped would have distributed NOTHING, because `Distribution_MiSTer`'s builder skips any file whose stem does not end in `_` plus eight digits, so an accepted core would appear in the Cores table and ship nothing with no error anywhere; two of the four R1/R2 residuals ADR 0002 closed turn out never to have been IRQ-timing residuals, resting on an assertion blargg WITHDREW in the successor ROM; `sys/` verbatim, the `.qsf`'s single seed table and the bitstream name all become checks instead of claims; the nine rung-1 gates run in CI against a PINNED oracle commit, so the accuracy evidence stops being a document describing a check a reader cannot run; `cpu_interrupts_v2` lands on the DUT as the first INDEPENDENT interrupt oracle, five of five; and `T-ORACLE-001`'s opening claim is retracted — RustyNES does clock the MMC3 counter on the pre-render line, which `2-details` sub-test 8 has asserted all along) → **v2.6.16 "Interlock"** (the arbiter's numbers describe a stimulus, not the console: the gate's worst case issues a CHR and a PRG request on the same cycle and the running console never does — zero coincidences and a minimum separation of two over fifteen million SDRAM cycles — so both published figures are wrong about it in opposite directions, the CPU MEETING its deadline at 24 against 24 on all 240,303 requests where the gate said it missed by four, and the fetch reaching 23 against a derived 22 that a `CHR_LAT` sweep shows understates the real tolerance by at least nine cycles; the slot-scheduled arbiter this version was scoped around is therefore NOT built, and is still worth building because zero margin means one added cycle anywhere breaks the CPU) → **v2.6.17 "Terminus"** (a write lands where the cycle ENDS, and this core does not move to meet it. The AccuracyCoin and TriCNES oracles re-sync to upstream, and the battery grows 141 → 144 assigned tests across two new pages that RE-HOME eleven existing PPU tests, so a re-sync is not an append and a suite-keyed baseline has to be regenerated rather than extended. `OAM2Address` becomes a live counter through sprite fetch and the frozen fetch reads index 0 for every byte, closing two of the three new tests and carrying an approved SAVE-STATE EPOCH — `PPU_SNAPSHOT_VERSION` 8 → 9, so pre-v9 `.rns` states no longer load. The third names the dots its two `$2001` writes land on, which is how the core came to be measured TWO DOTS EARLY on both: a 6502 commits a write at phi2, the last of a CPU cycle's three PPU dots, and this core applies PPU register writes at M2-low, the first. The move was then MADE, measured against a first-difference control captured beforehand, and NOT ADOPTED — the write alone reads 141/144 and breaks the independent `ppu_vbl_nmi/10-even_odd_timing`, and the best combination found reads 142/144 against the 143/144 that ships, for a save-state epoch and six re-baselined goldens. `mask_for_skip_check` is PROVEN to be a compensation for the placement — under phi2 it needs one delay stage rather than two — and two of its three dependants are still un-re-derived, so adopting the mechanism now would replace a documented compensation with an undocumented one. `Misaligned OAM2 Address` was also found to be passing via TWO CANCELLING ERRORS, both since fixed independently. Ships one core fix nothing in the corpus could see — the misaligned-OAM `+4 & $FC` realignment, reached 114 times per battery run out of 56,953,944 out-of-range branches and pinned by a targeted test because no ROM's verdict moves — plus `terminus_control.rs` as a standing first-difference gate. The follow-up version the write-placement work had been scoped into is FOLDED here: that split existed because Terminus was to be a scheduler change with its own ADR and a full re-baseline, and the refutation means it is not — so v2.6.17 carries both). v2.6.18 "Errata" closes the last AccuracyCoin entry on top of it, and v2.6.19 "Accession" carried both into the FPGA core, v2.6.20 "Telltale" made the OAM2 counter observable, v2.6.21 "Steward" gave the core battery saves and gated CHR writes during rendering, v2.6.22 "Rigging" built the instruments a board would need before the board existed, v2.6.23 "Pulse" closed the CHR-during-rendering divergence by making the `$2007` access pulse the rendering pipeline's load instead of computing its own, and v2.7.0 "Palisade", the first of the ADR 0041 audit line, made a corrupt save state fail at restore rather than one tick later, stopped pulse 1 muting on the `$4001 = $08` sweep idiom, and gave the save-state fuzz target the reach to find five defects the audit had not, v2.7.1 "Keepsake" stopped six cartridge boards writing an empty battery save and moved every user file the frontend writes onto the atomic writer, v2.7.2 "Bankroll" gave MMC1, MMC5 and Namco 163 the banks their boards have and made `$6000-$7FFF` read open bus on the 205 board variants with nothing there, v2.7.3 "Hearth" made the desktop keep battery saves, closed three ways around the Lua sandbox, gated script HTTP destinations, and brought audio back after a device change, v2.7.4 "Pocket" kept the Android and iOS apps alive through an internal error, gave both battery saves, paused them and released audio when they leave the screen, and made their saves atomic, v2.7.5 "Tally" closed the core audit's twelve performance proposals (eleven by measurement, one adopted: the audio buffer keeps its capacity), deprecated 18 dead bus methods and `ApuBus`, and closed the core and frontend ledgers, v2.7.6 "Recount" measured alone the six deletions v2.7.5 had bounded only together (five zero; the sixth, never reached by the benchmarks before, bounds at 0.2%), turned three redundant fast-path stores into an assertion, and shipped the contributed libretro buildbot fixes and targets, v2.8.0 "Bulkhead" opened the v2.8.x libretro + RTL audit line: the libretro core unwinds and contains a panic at its own boundary, keeps save states the right size with a Zapper, withdraws its memory maps on unload, loads through the standard `retro_game_info` via a vendored binding, and the save state carries the internal data bus, v2.8.1 "Gasket" closed the libretro audit: Four Score, four described ports (the list now NULL-terminated, where RetroArch had read past it), the Zapper unplugged, unclipped expansion audio, UNIF, and a Makefile that honours its callers, v2.8.2 "Solder" corrected the MiSTer core's on-die RTL against the oracle and the wiki (MMC3 acknowledge, SNROM PRG-RAM, the triangle and noise reload drop, the `$2002` latch) and found the audit's MMC1 finding inverted: the emulator was the one ignoring a reset, and v2.8.3 "Rivet" released every reset on the clock that uses it, cut the CPU by about 4% with two exact rewrites the fit report confirmed, and made the ladder run from a fresh checkout (164 of its 165 gates, a claim v2.8.4 found overstated), and v2.8.4 "Tether" closed the v2.8.x line: it found every off-die read would have been sampled a clock late on hardware (the SDRAM model and controller shared the error, and only the new SDRAM timing constraints could see it) and fixed both, pinned both builds at fitter seed 5 after a sweep of each, and made the ladder run all 165 gates from a clean checkout, and v2.9.0 "Survey" opened the v2.9.x line by re-running all four audits (139 rows re-verified, none regressed, 39 new findings fixed or dispositioned), including a Power Cycle that erased the battery save and an off-die MiSTer build that hung under `bootcore=`, v2.9.1 "Hone" found that the A/B tool had been timing the old code on both sides and re-measured what it had rejected, made a Vs. cabinet save-state about 9x faster, moved the off-die build's CHR into its own SDRAM bank, and pinned both builds at fitter seed 2 with two byte-identical clean compiles each, v2.9.2 "Candidate" triaged a fifth, 32-finding audit of both repositories (16 fixed, each finding with its evidence in its ledger), made save states keep the cartridge RAM of twelve board families that dropped it, fixed the MiSTer CPU losing an NMI raised inside a DMA, and cut the release-candidate bitstream pair for the SuperStation One session, v2.9.3 "Handset" moved every dependency to its newest release, answered all 244 review threads left open on PRs #7-#97 and fixed the ten findings that still held (mapper 28 rebuilt, a header editor that writes only what you change), and prepared the mobile device run, which the maintainer then moved after v3.0.0 with the board session, v2.9.4 "Plumb" recorded v3.0.0 as the API major with a release-candidate core (ADR 0043), made CI run the feature-gated tests, fuzz targets and a coverage floor it had only linted or never had, and corrected the records against the code, v2.9.5 "Caliper" gave every open accuracy item an outcome: blargg's `apu_test` frame-counter coincidence, the composite 2C02's odd-frame scanline-0 sprite pixel, OAM DMA filling the PPU I/O latch and KS7032's `$6000` window fixed red first, 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11), v2.9.6 "Roster" added seventeen mapper families from their NESdev pages (174 → 191), promoted GTROM to Curated with a modelled SST39SF040 whose saves persist, corrected mapper 4's NES 2.0 submappers (MMC6, NEC, MC-ACC, T9552), and re-baselined the local commercial suites, which had drifted unread since about v2.0.0, v2.9.7 "Tandem" brought the desktop's features to the web and phones (battery saves in the browser, Vs. DualSystem on web and mobile, FDS and NSF on mobile), made the release binaries the full native build, and fixed a PPU A12 defect real games exposed: the PPU reported one pulse per scanline where the console makes eight, so Acclaim's MC-ACC, the J.Y. ASIC and mapper 91 had counted eight times slow, v2.9.8 "Vanguard" carried v3.0.0's planned breaking changes ahead of it (a save identity that ignores the header, older save states and movies refused, movies and netplay that record the machine, the ADR 0042 API removals), booted every one of 748 staged dumps and fixed the defects that turned up, and made the game database's corrections reach every platform, v2.9.9 "Ballast", the release candidate for v3.0.0, re-ran all four audits and fixed what they found, raised the MMC3 IRQ by the NESdev rule (T-ORACLE-001) and gave the MMC5 the chip's CHR set selection (which fixed *Uchuu Keibitai SDF*), made audio exact across a save state, moved the MiSTer core's oracle pin onto it with the APU and PPU parity that opened, and cut the release-candidate bitstream pair at seed 6 (a seed-8 pair was withdrawn when the RTL changed after it), v3.0.0 "Cornerstone", the API major, gathered every break since v2.x in one place, made movies and netplay carry a core timing epoch so a version that emulates differently is refused with a reason (ADR 0045), took the last struct-extensibility breaks, closed blargg's `4-scanline_timing` in the emulator and the MiSTer core (T-MMC3-BG-A12), and shipped that core as a release candidate, not hardware-verified, and v3.0.1 "Mortar", the current release, unbanked *Famicom Yarou Vol.1*'s CHR-RAM (raising the emulation epoch to 2), reached the MiSTer core's odd-frame A12 exception with a new test ROM that found a one-cycle defect, moved the toolchain and the libretro buildbot to Rust 1.99, answered every bot review left since PR #1, recorded the shared Bisqwit NTSC pass as derived and moved the TriCNES source out of the repository, and wrote the plan to v4.0.0 (`to-dos/plans/v3.1-to-v4.0-line-plan.md`). AccuracyCoin held **141/141** through v2.6.16, read **143/144 (99.31%)** at v2.6.17, where the upstream re-sync grew the battery to 144 assigned tests and `Frozen OAM2 Increment` was the single named failure, and has read **144/144 (100.00%)** since v2.6.18 closed it — and the number was never always *by construction*: v2.3.4, v2.3.7, v2.3.9, the rung-3 releases v2.5.4-v2.5.6 and v2.6.17 change the core, so for those it is **verified** rather than inherited, and saying which is which is the point. **Full per-release detail is in `CHANGELOG.md` and `docs/STATUS.md` (the single source of truth)** — the entries below (v2.1.0 "Fathom" was the prior anchor here; v2.0.8 → v2.0.1) are the older historical trail, retained rather than duplicated. +- **Release line since v2.1.0:** the v2.1.x **"Fathom"** accuracy line (v2.1.0 → v2.1.10) → **v2.2.0 "Capstone"** (the milestone cut closing the "deepen the existing project" run) → **v2.2.1** (housekeeping) → **v2.2.2 "Conduit"** (build / distribution / CI-integrity) → **v2.2.3 "Datum"** (performance appraisal + the last two Holy Mapperel residuals closed) → **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut) → **v2.2.5 "Colophon"** → **v2.2.6 "Almanac"** → **v2.2.7 "Timbre II"** → **v2.2.8 "Aperture II"** → **v2.2.9 "Studio II"** → **v2.3.0 "Datum II"** → **v2.3.1 "Plumb Line"** → **v2.3.2 "Lucid"** → **v2.3.3 "Cadence"** → **v2.3.4 "Ledger"** → **v2.3.5 "Manifest"** → **v2.3.6 "Sounding"** → **v2.3.7 "Overtone"** → **v2.3.8 "Parallax"** → **v2.3.9 "Crucible"** → the **v2.4.x "Fabric"** co-simulation line (**v2.4.1 "Fabric"** → **v2.4.2 "Cairn"** → **v2.4.3 "Touchstone"** → **v2.4.4 "Ignition"** → **v2.4.5 "Compass"** → **v2.4.6 "Abacus"** → **v2.4.7 "Keystone"** → **v2.4.8 "Palimpsest"** → **v2.4.9 "Plumbline II"** → **v2.5.0 "Rungwork"** → **v2.5.1 "Retrace"** → **v2.5.2 "Dormant"** → **v2.5.3 "Hysteresis"** → **v2.5.4 "Escapement"** → **v2.5.5 "Raster"** → **v2.5.6 "Vestige"** → **v2.5.7 "Collimation"** → **v2.5.8 "Blanking"** → **v2.5.9 "Overture"** → **v2.6.0 "Assay"** → **v2.6.1 "Interleave"** → **v2.6.2 "Witness"** → **v2.6.3 "Mainspring"** → **v2.6.4 "Rubric"** → **v2.6.5 "Muster"** → **v2.6.6 "Chassis"** → **v2.6.7 "Detent"** (the bitstream becomes a published, reproducible release artifact and a one-cycle disagreement is attributed rather than fitted away) → **v2.6.8 "Arrears"** (the gates the previous release fixed and never widened: four of six denied checkpoint streams had been passing unnoticed, three of them not run by the suite at all, and nestest widens 19x and gains the nine-field comparison that closes the gate's stated `nmi_line` blind spot) → **v2.6.9 "Abeyance"** (an exclusion hides improvement as well as regression -- both denied co-simulation streams close, and the larger one was a defect in the HARNESS rather than the console, carried for seven releases behind the phrase "by design") → **v2.6.10 "Inference"** (the cartridge meets the synthesiser, and simulation could not have asked the question -- rung 7's five boards had never been through Quartus, and `chr` written from two separate `always_ff` blocks could not infer as an M10K, so 128 KB stayed in flip-flops: 1,048,576 registers against roughly 166,000. The fix is behaviourally invisible -- 141 gates unchanged -- and the release carries the bitstream the previous one could not produce) → **v2.6.11 "Exposure"** (a picture is a gate the ladder did not have -- all 141 co-simulation gates green and two of six commercial games rendering wrong, because only THREE of those gates compare a framebuffer and all three ship CHR-ROM, so a CHR-RAM write taking the shared-pin composite address built for fetches was reached 9,600 times and compared never)) -> **v2.6.12 "Groundwork"** (the bitstream was an NROM-only console -- `emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND and the fitted cartridge was mapper 0 with 8 KiB of PRG and 8 KiB of CHR against a declared 256 and 128; no gate could see it because `emu.sv` is not in the testbench file list, so all 142 gates exercised a correctly-configured cartridge, and Quartus said so three times in messages that cite an instance path rather than a file) -> **v2.6.13 "Slack"** (the cartridge outgrows the die -- an SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge written from the AS4C32M16SB-7 datasheet, and three consumers measured to three different budgets rather than the one figure the previous step assumed; the PPUDATA port leaves the shared bus through a handshake and that fix shipped a defect only a banked cartridge could see, the request carrying the raw PPU address where the mapper's translation was needed, so every banked-CHR board read bank zero; off the die the console passes 142 of 142 and it still ships on the die, because an off-die core cannot run without the add-on) -> **v2.6.14 "Docket"** (the submission checklist becomes auditable -- 30 boxes, 16 unticked and fourteen of those saying nothing about why, so an unticked box could not be told apart from work outstanding, work blocked elsewhere, and work already done and never ticked; the third case occurred five times. One box asked `docs/provenance.md` to state that no NES core was ever opened, which that document's own Do-not-self-certify section forbids, so it could only have been ticked by writing the sentence the provenance rules exist to prevent. Re-measuring the ticked half found two claims that had expired, the task board four delivered items never ticked and an SDRAM precondition the previous release did not follow, and its claim that MMC1 was unimplemented is retracted) → **v2.6.15 "Warrant"** (the claims the submission will make become checkable, and the instrument pays the oracle back — the `.rbf` name this core shipped would have distributed NOTHING, because `Distribution_MiSTer`'s builder skips any file whose stem does not end in `_` plus eight digits, so an accepted core would appear in the Cores table and ship nothing with no error anywhere; two of the four R1/R2 residuals ADR 0002 closed turn out never to have been IRQ-timing residuals, resting on an assertion blargg WITHDREW in the successor ROM; `sys/` verbatim, the `.qsf`'s single seed table and the bitstream name all become checks instead of claims; the nine rung-1 gates run in CI against a PINNED oracle commit, so the accuracy evidence stops being a document describing a check a reader cannot run; `cpu_interrupts_v2` lands on the DUT as the first INDEPENDENT interrupt oracle, five of five; and `T-ORACLE-001`'s opening claim is retracted — RustyNES does clock the MMC3 counter on the pre-render line, which `2-details` sub-test 8 has asserted all along) → **v2.6.16 "Interlock"** (the arbiter's numbers describe a stimulus, not the console: the gate's worst case issues a CHR and a PRG request on the same cycle and the running console never does — zero coincidences and a minimum separation of two over fifteen million SDRAM cycles — so both published figures are wrong about it in opposite directions, the CPU MEETING its deadline at 24 against 24 on all 240,303 requests where the gate said it missed by four, and the fetch reaching 23 against a derived 22 that a `CHR_LAT` sweep shows understates the real tolerance by at least nine cycles; the slot-scheduled arbiter this version was scoped around is therefore NOT built, and is still worth building because zero margin means one added cycle anywhere breaks the CPU) → **v2.6.17 "Terminus"** (a write lands where the cycle ENDS, and this core does not move to meet it. The AccuracyCoin and TriCNES oracles re-sync to upstream, and the battery grows 141 → 144 assigned tests across two new pages that RE-HOME eleven existing PPU tests, so a re-sync is not an append and a suite-keyed baseline has to be regenerated rather than extended. `OAM2Address` becomes a live counter through sprite fetch and the frozen fetch reads index 0 for every byte, closing two of the three new tests and carrying an approved SAVE-STATE EPOCH — `PPU_SNAPSHOT_VERSION` 8 → 9, so pre-v9 `.rns` states no longer load. The third names the dots its two `$2001` writes land on, which is how the core came to be measured TWO DOTS EARLY on both: a 6502 commits a write at phi2, the last of a CPU cycle's three PPU dots, and this core applies PPU register writes at M2-low, the first. The move was then MADE, measured against a first-difference control captured beforehand, and NOT ADOPTED — the write alone reads 141/144 and breaks the independent `ppu_vbl_nmi/10-even_odd_timing`, and the best combination found reads 142/144 against the 143/144 that ships, for a save-state epoch and six re-baselined goldens. `mask_for_skip_check` is PROVEN to be a compensation for the placement — under phi2 it needs one delay stage rather than two — and two of its three dependants are still un-re-derived, so adopting the mechanism now would replace a documented compensation with an undocumented one. `Misaligned OAM2 Address` was also found to be passing via TWO CANCELLING ERRORS, both since fixed independently. Ships one core fix nothing in the corpus could see — the misaligned-OAM `+4 & $FC` realignment, reached 114 times per battery run out of 56,953,944 out-of-range branches and pinned by a targeted test because no ROM's verdict moves — plus `terminus_control.rs` as a standing first-difference gate. The follow-up version the write-placement work had been scoped into is FOLDED here: that split existed because Terminus was to be a scheduler change with its own ADR and a full re-baseline, and the refutation means it is not — so v2.6.17 carries both). v2.6.18 "Errata" closes the last AccuracyCoin entry on top of it, and v2.6.19 "Accession" carried both into the FPGA core, v2.6.20 "Telltale" made the OAM2 counter observable, v2.6.21 "Steward" gave the core battery saves and gated CHR writes during rendering, v2.6.22 "Rigging" built the instruments a board would need before the board existed, v2.6.23 "Pulse" closed the CHR-during-rendering divergence by making the `$2007` access pulse the rendering pipeline's load instead of computing its own, and v2.7.0 "Palisade", the first of the ADR 0041 audit line, made a corrupt save state fail at restore rather than one tick later, stopped pulse 1 muting on the `$4001 = $08` sweep idiom, and gave the save-state fuzz target the reach to find five defects the audit had not, v2.7.1 "Keepsake" stopped six cartridge boards writing an empty battery save and moved every user file the frontend writes onto the atomic writer, v2.7.2 "Bankroll" gave MMC1, MMC5 and Namco 163 the banks their boards have and made `$6000-$7FFF` read open bus on the 205 board variants with nothing there, v2.7.3 "Hearth" made the desktop keep battery saves, closed three ways around the Lua sandbox, gated script HTTP destinations, and brought audio back after a device change, v2.7.4 "Pocket" kept the Android and iOS apps alive through an internal error, gave both battery saves, paused them and released audio when they leave the screen, and made their saves atomic, v2.7.5 "Tally" closed the core audit's twelve performance proposals (eleven by measurement, one adopted: the audio buffer keeps its capacity), deprecated 18 dead bus methods and `ApuBus`, and closed the core and frontend ledgers, v2.7.6 "Recount" measured alone the six deletions v2.7.5 had bounded only together (five zero; the sixth, never reached by the benchmarks before, bounds at 0.2%), turned three redundant fast-path stores into an assertion, and shipped the contributed libretro buildbot fixes and targets, v2.8.0 "Bulkhead" opened the v2.8.x libretro + RTL audit line: the libretro core unwinds and contains a panic at its own boundary, keeps save states the right size with a Zapper, withdraws its memory maps on unload, loads through the standard `retro_game_info` via a vendored binding, and the save state carries the internal data bus, v2.8.1 "Gasket" closed the libretro audit: Four Score, four described ports (the list now NULL-terminated, where RetroArch had read past it), the Zapper unplugged, unclipped expansion audio, UNIF, and a Makefile that honours its callers, v2.8.2 "Solder" corrected the MiSTer core's on-die RTL against the oracle and the wiki (MMC3 acknowledge, SNROM PRG-RAM, the triangle and noise reload drop, the `$2002` latch) and found the audit's MMC1 finding inverted: the emulator was the one ignoring a reset, and v2.8.3 "Rivet" released every reset on the clock that uses it, cut the CPU by about 4% with two exact rewrites the fit report confirmed, and made the ladder run from a fresh checkout (164 of its 165 gates, a claim v2.8.4 found overstated), and v2.8.4 "Tether" closed the v2.8.x line: it found every off-die read would have been sampled a clock late on hardware (the SDRAM model and controller shared the error, and only the new SDRAM timing constraints could see it) and fixed both, pinned both builds at fitter seed 5 after a sweep of each, and made the ladder run all 165 gates from a clean checkout, and v2.9.0 "Survey" opened the v2.9.x line by re-running all four audits (139 rows re-verified, none regressed, 39 new findings fixed or dispositioned), including a Power Cycle that erased the battery save and an off-die MiSTer build that hung under `bootcore=`, v2.9.1 "Hone" found that the A/B tool had been timing the old code on both sides and re-measured what it had rejected, made a Vs. cabinet save-state about 9x faster, moved the off-die build's CHR into its own SDRAM bank, and pinned both builds at fitter seed 2 with two byte-identical clean compiles each, v2.9.2 "Candidate" triaged a fifth, 32-finding audit of both repositories (16 fixed, each finding with its evidence in its ledger), made save states keep the cartridge RAM of twelve board families that dropped it, fixed the MiSTer CPU losing an NMI raised inside a DMA, and cut the release-candidate bitstream pair for the SuperStation One session, v2.9.3 "Handset" moved every dependency to its newest release, answered all 244 review threads left open on PRs #7-#97 and fixed the ten findings that still held (mapper 28 rebuilt, a header editor that writes only what you change), and prepared the mobile device run, which the maintainer then moved after v3.0.0 with the board session, v2.9.4 "Plumb" recorded v3.0.0 as the API major with a release-candidate core (ADR 0043), made CI run the feature-gated tests, fuzz targets and a coverage floor it had only linted or never had, and corrected the records against the code, v2.9.5 "Caliper" gave every open accuracy item an outcome: blargg's `apu_test` frame-counter coincidence, the composite 2C02's odd-frame scanline-0 sprite pixel, OAM DMA filling the PPU I/O latch and KS7032's `$6000` window fixed red first, 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11), v2.9.6 "Roster" added seventeen mapper families from their NESdev pages (174 → 191), promoted GTROM to Curated with a modelled SST39SF040 whose saves persist, corrected mapper 4's NES 2.0 submappers (MMC6, NEC, MC-ACC, T9552), and re-baselined the local commercial suites, which had drifted unread since about v2.0.0, v2.9.7 "Tandem" brought the desktop's features to the web and phones (battery saves in the browser, Vs. DualSystem on web and mobile, FDS and NSF on mobile), made the release binaries the full native build, and fixed a PPU A12 defect real games exposed: the PPU reported one pulse per scanline where the console makes eight, so Acclaim's MC-ACC, the J.Y. ASIC and mapper 91 had counted eight times slow, v2.9.8 "Vanguard" carried v3.0.0's planned breaking changes ahead of it (a save identity that ignores the header, older save states and movies refused, movies and netplay that record the machine, the ADR 0042 API removals), booted every one of 748 staged dumps and fixed the defects that turned up, and made the game database's corrections reach every platform, v2.9.9 "Ballast", the release candidate for v3.0.0, re-ran all four audits and fixed what they found, raised the MMC3 IRQ by the NESdev rule (T-ORACLE-001) and gave the MMC5 the chip's CHR set selection (which fixed *Uchuu Keibitai SDF*), made audio exact across a save state, moved the MiSTer core's oracle pin onto it with the APU and PPU parity that opened, and cut the release-candidate bitstream pair at seed 6 (a seed-8 pair was withdrawn when the RTL changed after it), v3.0.0 "Cornerstone", the API major, gathered every break since v2.x in one place, made movies and netplay carry a core timing epoch so a version that emulates differently is refused with a reason (ADR 0045), took the last struct-extensibility breaks, closed blargg's `4-scanline_timing` in the emulator and the MiSTer core (T-MMC3-BG-A12), and shipped that core as a release candidate, not hardware-verified, and v3.0.1 "Mortar" unbanked *Famicom Yarou Vol.1*'s CHR-RAM (raising the emulation epoch to 2), reached the MiSTer core's odd-frame A12 exception with a new test ROM that found a one-cycle defect, moved the toolchain and the libretro buildbot to Rust 1.99, answered every bot review left since PR #1, recorded the shared Bisqwit NTSC pass as derived and moved the TriCNES source out of the repository, and wrote the plan to v4.0.0 (`to-dos/plans/v3.1-to-v4.0-line-plan.md`), and v3.1.0 "Bellwether", the current release, re-synced AccuracyCoin to upstream `f5f41dc2` and fixed the two defects it found (raising the epoch to 3), made the CPU overclock and the sprite-limit option work and travel with movies and netplay, corrected PAL emphasis and the alternate MMC3, gave the Vs. cabinet rewind and run-ahead, and matched the MiSTer core to all of it, including a `$2006` timing rule it had never been tested against. AccuracyCoin held **141/141** through v2.6.16, read **143/144 (99.31%)** at v2.6.17, where the upstream re-sync grew the battery to 144 assigned tests and `Frozen OAM2 Increment` was the single named failure, and read **144/144 (100.00%)** from v2.6.18 — **a score v3.1.0 showed was overstated**: the old ROM's `Misaligned OAM behavior` fail path fell through to a pass, hiding a real failure in every one of those releases; on upstream `f5f41dc2` the core reads **146/146** with that failure fixed — and the number was never always *by construction*: v2.3.4, v2.3.7, v2.3.9, the rung-3 releases v2.5.4-v2.5.6 and v2.6.17 change the core, so for those it is **verified** rather than inherited, and saying which is which is the point. **Full per-release detail is in `CHANGELOG.md` and `docs/STATUS.md` (the single source of truth)** — the entries below (v2.1.0 "Fathom" was the prior anchor here; v2.0.8 → v2.0.1) are the older historical trail, retained rather than duplicated. - **Historical detail — v2.0.8 "Harbor"** (2026-07-09) (the preceding release is v2.9.9 "Ballast"; see the release line above) — the eighth release of the **v2.0.x mobile-finalization train** and the **iOS release candidate** ("Harborlight"), the final release of the iOS finalization window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.7** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched). It stages the App Store scaffolding for v2.1.0: version-controlled **App Store Connect listing metadata** (`fastlane/metadata/ios/{en-US,es-ES}/`, mirroring the Android tree, files-only), a **dormant App Store `release` lane** in `fastlane/Fastfile` that stages the build + listing but **does not submit** (`submit_for_review: false`) and is **not** CI-wired (the interim channel stays **TestFlight**), and an **App-Review §4.7 self-audit** (no bundled/downloadable ROMs, ownership notice, searchable library, 4+ rating) in `docs/ios-v2.0.8-readiness.md`. Version bump (workspace `2.0.7 → 2.0.8`; iOS `MARKETING_VERSION → 2.0.8`). **No store submission** (that is v2.1.0); screenshots, real signing, the listing upload, and the App-Review submission are the **maintainer / v2.0.9 / v2.1.0** closeout. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.8]` + `docs/ios-v2.0.8-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. - **Earlier in the train:** **RustyNES v2.0.7 "Harbor"** (2026-07-09) — the seventh release of the **v2.0.x mobile-finalization train** and the **third iOS finalization release** ("Trim"), continuing the iOS window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.6** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched). It wires the **App Store submission floor** (Apple mandates the **iOS 26 SDK / Xcode 26** for every App Store Connect upload from **2026-04-28**, so the tag-gated iOS CI now selects the newest Xcode 26.x on the runner — a build-SDK pin, non-breaking fallback on older images), **reconciles the deployment target `iOS 15.0 → 17.0`** to match the code's real API floor (`NavigationStack` iOS 16 + `.topBarTrailing` iOS 17, unguarded at 12+ sites — the prior 15.0 was never buildable), and **re-audits `PrivacyInfo.xcprivacy`** against the v2.0.6 crash reporter (no new data type / required-reason API — local-only, backup-excluded, off by default). Version bump (workspace `2.0.6 → 2.0.7`; iOS `MARKETING_VERSION → 2.0.7`). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); on-device profiling + the Xcode-26 archive are a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.7]` + `docs/ios-v2.0.7-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. - **Earlier in the train:** **RustyNES v2.0.6 "Harbor"** (2026-07-09) — the sixth release of the **v2.0.x mobile-finalization train** and the **second iOS finalization release** ("Parity"), continuing the iOS window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.5** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It adds a **new opt-in, privacy-first crash-reporting surface** (off by default — the iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 iOS-applicable deferral): **Settings → Diagnostics** installs an uncaught-`NSException` handler that writes **local** crash logs the user can view + copy in-app — **nothing is uploaded**, so the "Data Not Collected" privacy label is unchanged (EN + ES); the handler re-checks the live opt-in at crash time so opting out stops new logs immediately. It also records the **feature-parity re-verification** of the v1.9.x host features (Game Center, CloudKit save sync, MFi controllers, capture / PiP, accessibility) against the unchanged v2.0.0 bridge surface. Version bump (workspace `2.0.5 → 2.0.6`; iOS `MARKETING_VERSION → 2.0.6`). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); on-device crash-capture verification is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.6]` + `docs/ios-v2.0.6-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. From 5e99b073976aae0890a59544509053573132ff9e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 04:33:24 -0400 Subject: [PATCH 11/21] fix(test): two v3.1.0 tests read ROMs that exist only on the dev host CI's test-roms job on the release PR (#594, run 37747083839) failed all five cpu_overclock tests: "read .../tests/roms/nes-test-roms/apu_mixer/ square.nes: No such file or directory". `tests/roms/nes-test-roms/` is GITIGNORED (.gitignore:160), a local aggregate; only a few of its files are force-tracked. Both tests passed locally for exactly that reason, and cargo stopped at the first failing binary, so epoch_fingerprint (which reads four ROMs from the same place) never ran in CI at all. - cpu_overclock: ROM -> blargg/apu_mixer/triangle.nes, AUDIBLE_ROM -> blargg/apu_mixer/square.nes (cmp: byte-identical to the aggregate copy). - epoch_fingerprint's panel: the aggregate's 4-scanline_timing and flowing_palette -> the tracked blargg/mmc3_test_2/4-scanline_timing.nes and assorted/flowing_palette.nes (both cmp-identical; their rows keep the same four hashes, only the path column moves, which is the check that the swap changed nothing); ny2011 and spritecans have no tracked copy and become blargg/apu_mixer/triangle.nes (a continuous tone through the mixer) and blargg/sprite_overflow_tests/3.Timing.nes (evaluation and overflow timing). The table was re-blessed with last_release_epoch set to 2 for the bless only, then restored to 3: a panel change is not a behaviour change, and the gate cannot tell the two apart, so its refusal to bless at the released epoch had to be stepped around deliberately. The plain run at 3 passes; cpu_overclock 6/6. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../golden/epoch_fingerprint.tsv | 8 ++++---- .../tests/cpu_overclock.rs | 15 +++++++++----- .../tests/epoch_fingerprint.rs | 20 +++++++++++++------ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv index 1fd1fa497..b31460365 100644 --- a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv +++ b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv @@ -7,9 +7,9 @@ # last_release_epoch 3 # rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158 -nes-test-roms/ny2011/ny2011.nes 600 118f6b763cc1c58a dda9e74ae2696052 538041de1d7bd79f 17838523 -nes-test-roms/spritecans-2011/spritecans.nes 300 e8caa6ca3d863712 fbdef8039c470bda e53aaa23069253a9 8904372 +blargg/apu_mixer/triangle.nes 600 1b6dac56c42f267a 87f291ef58273cac 983e19fe15e197d6 17838587 +blargg/sprite_overflow_tests/3.Timing.nes 300 1ff4fd9a87d46e5a f7f5b37c7a51cc3d d7e9312891460c14 8904391 nes-test-roms/dmc_tests/latency.nes 240 b112947dcb56f813 2b4cacae717f270b 286ebb8ad39d4642 7117579 -nes-test-roms/mmc3_test_2/rom_singles/4-scanline_timing.nes 120 c1423fdfef9a85fa cceb1ba4374551ca 73baf7b42fa5860e 3543898 +blargg/mmc3_test_2/4-scanline_timing.nes 120 c1423fdfef9a85fa cceb1ba4374551ca 73baf7b42fa5860e 3543898 nes-test-roms/sprdma_and_dmc_dma/sprdma_and_dmc_dma.nes 120 bb655ab4bc5277ac 9a8fbf6de707b966 a0779243e8ce2902 3543900 -nes-test-roms/full_palette/flowing_palette.nes 120 72990514f3e3bfb3 8a91bbbb1ead50e8 27337e564377faba 3543884 +assorted/flowing_palette.nes 120 72990514f3e3bfb3 8a91bbbb1ead50e8 27337e564377faba 3543884 diff --git a/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs index 301a43a9c..5c971b66d 100644 --- a/crates/rustynes-test-harness/tests/cpu_overclock.rs +++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs @@ -29,11 +29,16 @@ use common::{fnv1a64, rom_path}; use rustynes_core::save_state::{BinReader, BinWriter}; use rustynes_core::{HardwareOptions, Nes}; -const ROM: &str = "nes-test-roms/ny2011/ny2011.nes"; -/// Audible from about frame 120 (ny2011 is silent for its first 300 frames, -/// which made the snapshot test below blind to the APU's phase: a restore -/// that dropped the stock-rate position passed it). -const AUDIBLE_ROM: &str = "nes-test-roms/apu_mixer/square.nes"; +/// Committed ROMs only. This file first named `nes-test-roms/ny2011` and +/// `nes-test-roms/apu_mixer/square.nes`, which live in the gitignored local +/// aggregate: every test here passed on the development host and failed in +/// CI's clean checkout (v3.1.0's release PR, #594). `blargg/apu_mixer/` is the +/// tracked copy (`square.nes` byte-identical). +const ROM: &str = "blargg/apu_mixer/triangle.nes"; +/// Audible from about frame 120. A silent window (`ny2011` is silent for its +/// first 300 frames) made the snapshot test below blind to the APU's phase: a +/// restore that dropped the stock-rate position passed it. +const AUDIBLE_ROM: &str = "blargg/apu_mixer/square.nes"; fn boot_rom(rom: &str) -> Nes { let path = rom_path(rom); diff --git a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs index b8a29c09e..fc49d66c7 100644 --- a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs +++ b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs @@ -67,16 +67,16 @@ const PANEL: &[Probe] = &[ reach: "the whole AccuracyCoin battery: CPU, PPU, APU, DMA, bus", }, Probe { - rom: "nes-test-roms/ny2011/ny2011.nes", + rom: "blargg/apu_mixer/triangle.nes", frames: 600, press_start: false, - reach: "music and sprites", + reach: "a continuous tone through the mixer (audio)", }, Probe { - rom: "nes-test-roms/spritecans-2011/spritecans.nes", + rom: "blargg/sprite_overflow_tests/3.Timing.nes", frames: 300, press_start: false, - reach: "sprite evaluation and overflow", + reach: "sprite evaluation and overflow timing", }, Probe { rom: "nes-test-roms/dmc_tests/latency.nes", @@ -85,7 +85,7 @@ const PANEL: &[Probe] = &[ reach: "DMC fetch latency (audio)", }, Probe { - rom: "nes-test-roms/mmc3_test_2/rom_singles/4-scanline_timing.nes", + rom: "blargg/mmc3_test_2/4-scanline_timing.nes", frames: 120, press_start: false, reach: "MMC3 IRQ timing", @@ -97,13 +97,21 @@ const PANEL: &[Probe] = &[ reach: "OAM and DMC DMA overlap", }, Probe { - rom: "nes-test-roms/full_palette/flowing_palette.nes", + rom: "assorted/flowing_palette.nes", frames: 120, press_start: false, reach: "palette and emphasis output", }, ]; +// Every ROM above is COMMITTED. The panel first named four from the +// gitignored local aggregate `tests/roms/nes-test-roms/` (ny2011, spritecans, +// and the aggregate's copies of `4-scanline_timing` and `flowing_palette`), +// so it passed on the development host and could not run in CI's clean +// checkout (v3.1.0's release PR, #594). The two copies are byte-identical to +// the tracked files they now name; `ny2011` and `spritecans` have no tracked +// copy and were replaced by the nearest committed stimulus. + /// Frames to let a ROM boot before `AccuracyCoin`'s START press. const BOOT_FRAMES: u32 = 300; /// Frames START is held. From 11c96b9b487a341e6579cf15d97a20752a1d71b3 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 04:50:14 -0400 Subject: [PATCH 12/21] fix(core): make the CPU overclock multiplier exact on PAL and Dendy The v3.1.0 CPU overclock divided the region's CPU cycle length by the multiplier with integer division: `cpu_div_effective = cpu_div_cached / k`. That is exact only when the divider is a multiple of k. NTSC's 12 master clocks divide by 2, 3 and 4; PAL's 16 does not divide by 3 and Dendy's 15 does not divide by 2 or 4. So PAL x3 ran 16/3 = 5-clock cycles, x3.2, and Dendy x4 ran 15/4 = 3-clock cycles, x5. The stock-step bookkeeping (which CPU cycles also clock the APU, the DMC and the mapper hooks) was a separate master-clock debt, so sound and timers stayed right while the CPU ran too fast: the option said x3 and delivered x3.2. Reported by Copilot on the release PR (#594, `bus.rs:1424`); the report was correct. The fix replaces the debt with an exact schedule. The k overclocked cycles that make up one stock cycle have lengths overclock_cycle_len(div, k, phase) = ((phase+1)*div)/k - (phase*div)/k which sum to exactly `div` over phase 0..k-1 (a telescoping sum), and differ by at most one master clock: PAL x3 is 5, 5, 6; Dendy x4 is 3, 4, 4, 4. The last cycle of each group, `phase + 1 == k`, is the stock step, so the stock step lands exactly on the stock cycle boundary rather than drifting against it. `overclock_debt` becomes `overclock_phase` (0..k-1) in the BUS section 3 snapshot; restore clamps an out-of-range phase to k-1 and recomputes the cycle length from it, so a crafted state cannot stall the stock step. x1 is untouched: phase stays 0 and the length is `div`. Two details worth keeping: - The phase advances at the END of a cycle, in `cpu_clock_apu_dmc`, so the length a cycle runs with is chosen before its first half and the DMC end-of-cycle half still belongs to the stock step its start half ran in. - `overclock_cycle_len` is a const fn, so `.min()` is unavailable in the restore clamp; it is an if-expression for that reason. Verified: - New test `the_multiplier_is_exact_on_pal_and_dendy_too` counts CPU cycles over 60 frames at x1 and at x2, x3 and x4 on NTSC, PAL and Dendy, and requires each ratio within 0.1% of k; the defect it pins was 6.7% (PAL x3) and 25% (Dendy x4). Mutant (revert to `div / k`): fails with "PAL x3: 6383520 CPU cycles against 1994850 stock is x3.2000, not x3". - cpu_overclock 7/7, snapshot_schema_audit 9/9, epoch_fingerprint 1/1 (x1 is byte-identical; the panel runs at x1), rustynes-core lib 253/253. - fmt; clippy -D warnings for the workspace and the scripting, hd-pack, retroachievements and full feature sets. The overclock is new in this unreleased version, so the snapshot field's meaning changes without a further section bump: no released state carries it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 6 +- crates/rustynes-core/src/bus.rs | 92 ++++++++++++++----- crates/rustynes-core/src/bus_snapshot.rs | 19 ++-- .../tests/cpu_overclock.rs | 52 +++++++++++ .../tests/snapshot_schema_audit.rs | 2 +- docs/scheduler.md | 22 +++-- to-dos/plans/v3.1.0-plan.md | 2 +- 7 files changed, 150 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb295b9f5..b6d68f4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,7 +92,11 @@ run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** 2 to 4 times faster against the same picture and sound, removing slowdown. The APU, mapper IRQ counters and PPU timers stay at the stock rate, so pitch, tempo and raster effects are unchanged. Unlike the extra-scanline overclock, - movies record it and replay with it, and netplay players must match. + movies record it and replay with it, and netplay players must match. The + multiplier is exact on every region: a CPU cycle is 12 master clocks on NTSC + but 16 on PAL and 15 on Dendy, which do not divide by 3 or 4, so the + overclocked cycle lengths alternate to keep the average exact (a review of + the release PR found PAL x3 running at x3.2 and Dendy x4 at x5). - **"Disable 8-sprite-per-scanline limit" now works** (`T-SPRITE-LIMIT`; it was shown and saved but inert). It draws the dropped sprites behind the eight the console shows, and changes nothing the game can see: the overflow flag, the diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index e5a6ba097..2692d7aa2 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -587,21 +587,30 @@ pub struct SystemBus { /// for the header's. Configuration, re-applied to every mapper the bus /// builds (a power cycle rebuilds it); only mapper 4 acts on it. mmc3_revision_override: Option, - /// The master clocks one CPU cycle takes under the overclock: - /// `cpu_div_cached / cpu_overclock`, rounded down, so `x3` on PAL (16) - /// is 5 (x3.2) and `x2` on Dendy (15) is 7 (x2.14). Equals - /// `cpu_div_cached` at `x1`. This is what [`Bus::cpu_divider`] returns. + /// The master clocks the CPU cycle in progress takes under the overclock, + /// [`overclock_cycle_len`] of `overclock_phase`; `cpu_div_cached` at + /// `x1`. This is what [`Bus::cpu_divider`] returns. It changes only at a + /// cycle's END (`cpu_clock_apu_dmc`), because the CPU reads the divider + /// twice per cycle, once for each half, and both reads must agree. + /// + /// Until the v3.1.0 review (#594) this was `cpu_div_cached / k`, rounded + /// down: exact on NTSC (12 divides by 2, 3 and 4) and wrong elsewhere, + /// `x3` on PAL (16) running 3.2x and `x4` on Dendy (15) 5x, so a movie + /// that recorded "x4" did not get four times the CPU. cpu_div_effective: u8, - /// Master clocks the stock-rate domain is owed, `0..cpu_div_cached`. + /// Which of the `k` CPU cycles of the current stock cycle is in progress, + /// `0..cpu_overclock`. /// /// Under the overclock the APU, the DMC, the mappers' CPU-cycle hooks and /// the PPU's open-bus / post-reset timers stay at the STOCK rate, so a /// game gets more CPU time per frame without a pitch change, a faster - /// tempo or cycle-timed mapper IRQs firing early. Each CPU cycle adds - /// `cpu_div_effective`; a stock step runs whenever this reaches - /// `cpu_div_cached`. Unused (always 0) at `x1`, where every CPU cycle is a - /// stock step. In the BUS save-state section: run-ahead restores mid-run. - overclock_debt: u8, + /// tempo or cycle-timed mapper IRQs firing early. Every `k` CPU cycles + /// make one stock cycle exactly: cycle `i` of the group lasts + /// [`overclock_cycle_len`] master clocks, which sum to `cpu_div_cached` + /// over the group (PAL x3: 5, 5, 6), and the stock step runs on the last. + /// Always 0 at `x1`, where every CPU cycle is a stock step. In the BUS + /// save-state section: run-ahead restores mid-run. + overclock_phase: u8, /// The stock-rate domain's own cycle counter under the overclock, handed /// to the APU in place of the CPU cycle counter (the APU derives its /// put/get phase from it). Re-based to [`Self::cycle`] when the overclock @@ -1008,7 +1017,7 @@ impl SystemBus { cpu_overclock: 1, mmc3_revision_override: None, cpu_div_effective: cpu_div_cached, - overclock_debt: 0, + overclock_phase: 0, apu_cycle: 0, stock_step: true, open_bus: 0, @@ -1233,7 +1242,8 @@ impl SystemBus { self.ppu_clock = 0; // The stock-rate domain restarts with the clock; the multiplier itself // is configuration and survives the power cycle. - self.overclock_debt = 0; + self.overclock_phase = 0; + self.cpu_div_effective = overclock_cycle_len(self.cpu_div_cached, self.cpu_overclock, 0); self.apu_cycle = 0; self.stock_step = true; self.dma_byte = 0; @@ -1421,8 +1431,8 @@ impl SystemBus { self.apu_cycle = self.cycle; } self.cpu_overclock = k; - self.cpu_div_effective = self.cpu_div_cached / k; - self.overclock_debt = 0; + self.overclock_phase = 0; + self.cpu_div_effective = overclock_cycle_len(self.cpu_div_cached, k, 0); self.stock_step = true; } @@ -2907,7 +2917,7 @@ impl SystemBus { // identical across feature builds. dmc_halt: self.dmc_halt, dmc_load_write_delayed: self.dmc_load_write_delayed, - overclock_debt: self.overclock_debt, + overclock_phase: self.overclock_phase, apu_cycle: self.apu_cycle, uni_oam_active: self.uni_oam_active, uni_oam_halt: self.uni_oam_halt, @@ -2942,15 +2952,20 @@ impl SystemBus { self.dmc_halt = s.dmc_halt; self.dmc_load_write_delayed = s.dmc_load_write_delayed; // The overclock's stock-rate position. The multiplier itself is - // configuration (re-applied by the host); a debt the current divider - // could not have produced is clamped below one stock cycle so a stock - // step still comes due. - let max_debt = self.cpu_div_cached.saturating_sub(1); - self.overclock_debt = if s.overclock_debt > max_debt { - max_debt + // configuration (re-applied by the host); a phase the current + // multiplier could not have produced is clamped to its last cycle, so + // a stock step still comes due. The cycle length follows the phase. + let last = self.cpu_overclock.saturating_sub(1); + self.overclock_phase = if s.overclock_phase > last { + last } else { - s.overclock_debt + s.overclock_phase }; + self.cpu_div_effective = overclock_cycle_len( + self.cpu_div_cached, + self.cpu_overclock, + self.overclock_phase, + ); self.apu_cycle = s.apu_cycle; self.stock_step = true; self.uni_oam_active = s.uni_oam_active; @@ -4486,12 +4501,12 @@ impl Bus for SystemBus { let apu_cycle = if self.cpu_overclock == 1 { self.cycle } else { - self.overclock_debt += self.cpu_div_effective; - self.stock_step = self.overclock_debt >= self.cpu_div_cached; + // The stock step is the LAST cycle of each group of `k`; the phase + // itself advances at this cycle's end (`cpu_clock_apu_dmc`). + self.stock_step = self.overclock_phase + 1 >= self.cpu_overclock; if !self.stock_step { return; } - self.overclock_debt -= self.cpu_div_cached; self.apu_cycle = self.apu_cycle.wrapping_add(1); self.apu_cycle }; @@ -4518,6 +4533,18 @@ impl Bus for SystemBus { // matching Mesen's `ProcessCpuClock` at `StartCpuCycle`. So the END-of-cycle // DMC tick is a no-op here. fn cpu_clock_apu_dmc(&mut self) { + // v3.1.0: the overclock's phase moves to the next cycle HERE, after + // both of this cycle's `cpu_divider` reads (the CPU calls this once + // per cycle, DMA cycles included), so the next cycle's length is in + // place before its first half. + if self.cpu_overclock > 1 { + self.overclock_phase = (self.overclock_phase + 1) % self.cpu_overclock; + self.cpu_div_effective = overclock_cycle_len( + self.cpu_div_cached, + self.cpu_overclock, + self.overclock_phase, + ); + } // v3.1.0: the DMC end-of-cycle half belongs to the stock step its // start half ran in (always `true` at `x1`). if !self.stock_step { @@ -4741,6 +4768,21 @@ impl Bus for SystemBus { } } +/// v3.1.0 (`T-CPU-OVERCLOCK`): the master clocks CPU cycle `phase` of a +/// stock cycle takes at overclock `k`, for a region whose stock CPU cycle is +/// `div` master clocks. The lengths of phases `0..k` sum to exactly `div` +/// (they are the differences of `phase * div / k`), so `k` CPU cycles always +/// fill one stock cycle and the multiplier is exact on every region: NTSC +/// (12) gives 6/6, 4/4/4 and 3/3/3/3; PAL (16) 8/8, 5/5/6 and 4/4/4/4; +/// Dendy (15) 7/8, 5/5/5 and 3/4/4/4. At `k = 1` it is `div`. +const fn overclock_cycle_len(div: u8, k: u8, phase: u8) -> u8 { + let (div, k, phase) = (div as u16, k as u16, phase as u16); + // `k >= 1` by construction (`set_cpu_overclock` clamps); at most 16 * 4. + #[allow(clippy::cast_possible_truncation)] // a part of `div`, at most 16 + let len = (((phase + 1) * div) / k - (phase * div) / k) as u8; + len +} + #[cfg(test)] mod four_score_tests { use super::*; diff --git a/crates/rustynes-core/src/bus_snapshot.rs b/crates/rustynes-core/src/bus_snapshot.rs index 352e8ed2b..7b387b04c 100644 --- a/crates/rustynes-core/src/bus_snapshot.rs +++ b/crates/rustynes-core/src/bus_snapshot.rs @@ -28,7 +28,7 @@ //! last cycle of a store and the latch is consumed by the next opcode //! fetch, so a snapshot at that boundary without it would restore a //! three-cycle load where the machine was owed a four-cycle one; -//! - the CPU overclock's stock-rate position (`overclock_debt`, one byte, +//! - the CPU overclock's stock-rate position (`overclock_phase`, one byte, //! and `apu_cycle`, `u64`): under the overclock the APU and the mappers' //! cycle hooks advance on only some CPU cycles, and run-ahead restores in //! the middle of that pattern. @@ -154,7 +154,7 @@ pub fn encode_bus(bus: &SystemBus) -> Vec { // v3.1.0 (version 3): the DMC load-DMA write-refusal latch. w.u8(u8::from(s.dmc_load_write_delayed)); // v3.1.0 (version 3): the CPU overclock's stock-rate position. - w.u8(s.overclock_debt); + w.u8(s.overclock_phase); w.u64(s.apu_cycle); w.into_vec() } @@ -470,14 +470,14 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> bus.set_four_score_pending(fs); let internal_data_bus = r.u8()?; let dmc_load_write_delayed = r.bool()?; - let overclock_debt = r.u8()?; + let overclock_phase = r.u8()?; // A debt is below one stock CPU cycle (16 master clocks on PAL, the // longest); anything larger is a corrupt file, refused here rather than // clamped later. - if overclock_debt >= 16 { + if overclock_phase >= crate::MAX_CPU_OVERCLOCK { return Err(SnapshotError::SectionInvalid { tag: "BUS ".into(), - reason: format!("CPU-overclock debt {overclock_debt} out of range"), + reason: format!("CPU-overclock phase {overclock_phase} out of range"), }); } let apu_cycle = r.u64()?; @@ -504,7 +504,7 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> four_score_sig, dmc_halt, dmc_load_write_delayed, - overclock_debt, + overclock_phase, apu_cycle, uni_oam_active, uni_oam_halt, @@ -573,9 +573,10 @@ pub struct BusMiscState { /// v3.1.0 (BUS version 3): a pending load DMC DMA was refused by a CPU /// write and enters on the next read whichever half it is. pub dmc_load_write_delayed: bool, - /// v3.1.0 (BUS version 3): master clocks the CPU overclock's stock-rate - /// domain is owed (always 0 at `x1`). - pub overclock_debt: u8, + /// v3.1.0 (BUS version 3): which of the `k` CPU cycles of the current + /// stock cycle is in progress under the CPU overclock, `0..k` (always 0 + /// at `x1`); refused at or above `MAX_CPU_OVERCLOCK`. + pub overclock_phase: u8, /// v3.1.0 (BUS version 3): the stock-rate domain's cycle counter under /// the CPU overclock (unused at `x1`). pub apu_cycle: u64, diff --git a/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs index 5c971b66d..bbba2c0e7 100644 --- a/crates/rustynes-test-harness/tests/cpu_overclock.rs +++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs @@ -204,3 +204,55 @@ fn a_snapshot_under_the_overclock_restores_to_the_same_continuation() { "a restore under the overclock must replay the same frames, samples and cycles" ); } + +/// A minimal NES 2.0 NROM cart whose CPU spins in a `JMP` loop, with the +/// CPU/PPU timing byte (header byte 12) set: 0 NTSC, 1 PAL, 3 Dendy. +fn spin_rom(timing: u8) -> Vec { + let mut rom = vec![0u8; 16 + 16 * 1024 + 8 * 1024]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 1; // 16 KiB PRG + rom[5] = 1; // 8 KiB CHR + rom[7] = 0x08; // NES 2.0 + rom[12] = timing; + rom[16..19].copy_from_slice(&[0x4C, 0x00, 0xC0]); // $C000: JMP $C000 + let reset = 16 + (0xFFFC - 0xC000); + rom[reset..reset + 2].copy_from_slice(&[0x00, 0xC0]); + rom +} + +/// CPU cycles over `frames` frames of `spin_rom(timing)` at overclock `k`. +fn spin_cycles(timing: u8, k: u8, frames: u32) -> u64 { + let mut nes = Nes::from_rom(&spin_rom(timing)).expect("synthetic cart parses"); + nes.set_cpu_overclock(k); + for _ in 0..10 { + nes.run_frame(); + } + let start = nes.cycle(); + for _ in 0..frames { + nes.run_frame(); + } + nes.cycle() - start +} + +/// v3.1.0 review (#594): the multiplier is EXACT on every region. The CPU +/// divider was `div / k` rounded down, which only NTSC's 12 survives: PAL's +/// 16 at `x3` gave 5 (3.2x) and Dendy's 15 at `x4` gave 3 (5x), so a movie +/// recording "x4" did not get four times the CPU. Every `k` CPU cycles now +/// take exactly one stock cycle (alternating lengths), which this pins on all +/// three regions to within the frame-boundary jitter of a few cycles. +#[test] +fn the_multiplier_is_exact_on_pal_and_dendy_too() { + let frames = 60; + for (timing, region) in [(0u8, "NTSC"), (1, "PAL"), (3, "Dendy")] { + let stock = spin_cycles(timing, 1, frames); + for k in 2..=rustynes_core::MAX_CPU_OVERCLOCK { + let cycles = spin_cycles(timing, k, frames); + #[allow(clippy::cast_precision_loss)] + let ratio = cycles as f64 / stock as f64; + assert!( + (ratio - f64::from(k)).abs() < 0.001, + "{region} x{k}: {cycles} CPU cycles against {stock} stock is x{ratio:.4}, not x{k}" + ); + } + } +} diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index 427629d8c..92c741260 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -494,7 +494,7 @@ const CHIPS: &[Chip] = &[ "stock_step", "transient: set in `cpu_clock` and read by `cpu_clock_apu_dmc` within ONE CPU \ cycle; a snapshot falls between cycles, and restore sets it `true`. The \ - overclock's persistent position (`overclock_debt`, `apu_cycle`) IS \ + overclock's persistent position (`overclock_phase`, `apu_cycle`) IS \ serialized (BUS version 3)", ), ( diff --git a/docs/scheduler.md b/docs/scheduler.md index f74876c2c..e6c180b40 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -138,17 +138,23 @@ for PAL. ### CPU-multiplier overclock (v3.1.0, `T-CPU-OVERCLOCK`) `Nes::set_cpu_overclock(k)`, `k` in `1..=4` (`MAX_CPU_OVERCLOCK`), divides the -region's master-clock CPU divider: NTSC 12 becomes 6, 4 or 3; PAL 16 and Dendy -15 round down (PAL x3 is a divider of 5, x3.2). The PPU's divider is untouched, -so the CPU gets `k` times the cycles per frame. The smallest divider, 3, still -leaves both the read split `(0, 3)` and the write split `(2, 1)` valid. +region's master-clock CPU divider exactly: NTSC 12 becomes 6, 4 or 3 per +cycle; PAL 16 and Dendy 15 alternate cycle lengths so that every `k` cycles +take exactly one stock cycle (PAL `x3`: 5, 5, 6; Dendy `x4`: 3, 4, 4, 4; see +below). The PPU's divider is untouched, so the CPU gets exactly `k` times the +cycles per frame on every region. The shortest cycle, 3, still leaves both the +read split `(0, 3)` and the write split `(2, 1)` valid. Everything that measures console time stays at the stock rate: the APU (and with it the DMC), the mappers' `notify_cpu_cycle` IRQ counters, and the PPU's -open-bus decay and post-reset timers. The bus accumulates `overclock_debt`, the -master clocks the stock-rate domain is owed: each CPU cycle adds the effective -divider, and a **stock step** (those devices advance once) runs whenever the -debt reaches the stock divider. The APU is handed its own counter (`apu_cycle`) +open-bus decay and post-reset timers. Every `k` CPU cycles make exactly one +stock cycle: the bus counts `overclock_phase` through `0..k`, CPU cycle `i` of +the group lasts `((i+1)*div)/k - (i*div)/k` master clocks (the lengths sum to +the stock divider `div`), and a **stock step** (those devices advance once) +runs on the last cycle of each group. The cycle length changes only at a +cycle's end, because the CPU reads the divider once for each half of a cycle. +Until the v3.1.0 review the length was `div / k` rounded down, exact on NTSC +and not elsewhere (PAL `x3` ran 3.2x, Dendy `x4` 5x). The APU is handed its own counter (`apu_cycle`) instead of the CPU's, so its put/get phase advances once per stock step. DMA follows that phase, so a DMA takes about `k` times as many CPU cycles and the same real time. diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index cd03f1469..674bd78a2 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -90,7 +90,7 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | --- | --- | --- | | 1 | **Done.** Upstream `f5f41dc2`, 151 rows / 146 scored, **146 of 146**. Two defects fixed red first: the DMC load DMA a write refuses (4 cycles, not 3), and misaligned sprite evaluation (seed at dot 65, a four-byte copy from `m = 3`, the X range test). `Misaligned OAM behavior` had been a false pass on the old ROM (its fail path did not pop its return address, upstream `adacbc23`). `EMULATION_EPOCH` 3, BUS section 3. Extractor and `derive_indices.py` read `tblf1`/`tblf2` and fail closed; the mirror ROM is rebuilt. **Sibling consequence (read in `rtl/ppu2c02.sv`, 2026-10-07):** the RTL already seeds evaluation from OAMADDR at the end of the clear and already copies four bytes by a byte counter, but it never applies the X out-of-range `&$FC` (the inverse of the oracle's old defect); the DMC write-refusal path is unread yet. The pin move will move the AccuracyCoin goldens; the RTL needs the X rule (and the DMC rule, if absent), each with a gate that catches its mutant, written from the ROM's comments and nesdev, not from the oracle's Mesen2-derived evaluation region | battery red at errors 9, 3, 6, 7 in turn, each fix green; `misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x` catches 3 of 3 mutants; TriCNES per-cycle diff at the test-9 `STA $5000` | | 2 | **Done.** `tests/epoch_fingerprint.rs` + `golden/epoch_fingerprint.tsv` (epoch 3, `last_release_epoch` 2): seven panel ROMs, every frame's framebuffer, audio, end RAM, CPU cycles. Fails on moved output while the epoch equals the last release's and refuses that bless; after a rise, a re-bless suffices. ADR 0045 amended; the release cut must set `last_release_epoch` (`docs/agents/ci-and-release.md`) | with the cut simulated: control passes; both of this release's fixes reverted as mutants fail and their bless is refused (table unchanged); epoch 4 without bless fails, with bless passes; all files restored byte for byte | -| 3 | **Done.** `Nes::set_cpu_overclock(1..=4)`: the CPU divider is divided; the APU, DMC, mapper cycle hooks and PPU timers run on "stock steps" from a master-clock debt (`overclock_debt`, `apu_cycle`, BUS section 3); `run_frame`'s budget scales. `x1` byte-identical (epoch gate). Desktop: Settings combo, recorded by movies and matched by netplay (not held at stock like the scanline overclock). Not done: libretro and mobile options (neither exposes the scanline overclock either) | `cpu_overclock.rs` 6/6; mutants: every cycle a stock step (2 tests fail), `apu_cycle` parity flip and dropped debt on restore (snapshot test fails, after moving it to an audible ROM: ny2011 is silent for 300 frames and made it blind) | +| 3 | **Done.** `Nes::set_cpu_overclock(1..=4)`: the CPU divider is divided; the APU, DMC, mapper cycle hooks and PPU timers run on "stock steps" from an exact cycle schedule (`overclock_phase`, `apu_cycle`, BUS section 3; integer division made PAL x3 run x3.2 and Dendy x4 x5 until the PR #594 review); `run_frame`'s budget scales. `x1` byte-identical (epoch gate). Desktop: Settings combo, recorded by movies and matched by netplay (not held at stock like the scanline overclock). Not done: libretro and mobile options (neither exposes the scanline overclock either) | `cpu_overclock.rs` 6/6; mutants: every cycle a stock step (2 tests fail), `apu_cycle` parity flip and dropped debt on restore (snapshot test fails, after moving it to an audible ROM: ny2011 is silent for 300 frames and made it blind) | | 4 | **Done.** Render-only extras: up to 56 sprites fetched after the eighth real fetch, drawn behind the hardware eight, PPU snapshot v13. `Mapper::chr_reads_are_pure` (5 impure boards found by scanning every `ppu_read` body; pinned for every constructible id against `save_state`). Desktop checkbox now live | `sprite_limit.rs` 5/5 (RAM, audio, cycles identical frame by frame; blargg sprite_overflow 1-5 pass with it on); stimulus built in the test (spritecans never exceeds 7 per line, NEStress's 62 are transparent); mutants: extras not drawn, extras dropped on restore, MMC2 / J.Y. ASIC claiming purity, all caught | | 5 | **Done.** `.rnm` format 6 (minimum 6), netplay protocol 7 (`"RNE7"`, same `Sync` layout; `"RNE6"` refused naming its epoch); ADR 0044 amended | `a_format_5_movie_is_refused_as_too_old`, `a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch` | | 6 | **Done.** `emit_pixel` forms the emphasis index as the physical tint, exchanging PPUMASK bits 5/6 off NTSC (NESdev "Colour emphasis"), for both framebuffers. NTSC unchanged; the sibling has no PAL | `pal_and_dendy_swap_the_red_and_green_emphasis_bits` red first (PAL gave index 97, red, for bit 5) | From 69180c87c8c60fa6bd1bbfa4b01d7eb2ab1fedca Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 04:50:59 -0400 Subject: [PATCH 13/21] docs: correct four v3.1.0 records the release PR review found stale Copilot's review of #594 found four statements that this release made false or that its own fold dropped. Each was checked against the tree before it was changed. - Settings > Enhancements note (`i18n.rs`, EN and ES): it said enhancement modes are NEVER applied during TAS replay or netplay. v3.1.0 made two of them (the CPU overclock and the sprite-limit switch) part of HardwareOptions, so movies record them and netplay peers must match, while the scanline overclock is still ignored there. The note now says accuracy tests never use them and points at each option's own line, which already states its movie and netplay behaviour. - accuracy-ledger, Vs. DualSystem row: "wasm/mobile deferred". The mobile bridge has carried the cabinet since v2.9.7 (a screen switch, not two screens). The row now says so, and that its on-device rows (run sheet T8-T10) are NOT RUN with the Swift half uncompiled. A first draft of this edit called Android "verified on the emulator" and the web build "one screen"; T8 is NOT RUN and nothing was measured about the web build, so both claims were dropped before commit. - mobile run sheet: folding the v1.8.x checklist (638d5d34) kept the `foss` flavor check (G17) and lost the requirement that the `play` flavor be verified too. G18 restores it: `installPlayDebug`, boot a ROM, open Settings; the Play-Services surface (`android/app/src/play/`: Play Games, cloud save, Cast, in-app updates) appears, and a device without Play Services still boots and plays. - AccuracyCoin BUILD-PROVENANCE: the v3.1.0 paragraph described the frame-211 settle of the LEGACY `sprite-eval-misaligned-oam` build, which 3df59e8d replaced with a build from upstream f5f41dc2 (frame 175, 18/6). The paragraph now says the ROM was rebuilt and labels 211 as the replaced build's figure. The data rows are unchanged; accuracycoin_subtest_provenance passes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-frontend/src/i18n.rs | 2 +- docs/accuracy-ledger.md | 2 +- docs/mobile-v2.9.3-run-sheet.md | 1 + .../roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv | 12 +++++++----- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index 2780e442b..58a53919b 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -756,7 +756,7 @@ catalog! { SetOamDecayHover => "Model the 2C02's dynamic OAM losing un-refreshed sprite rows to a garbage pattern when rendering stays off (à la Mesen2). NTSC/Dendy only. Off is byte-identical to today's core.", Some("Modela cómo la OAM dinámica de la 2C02 pierde las filas de sprites no refrescadas y las convierte en un patrón basura cuando el renderizado permanece desactivado (como Mesen2). Solo NTSC/Dendy. Desactivado es idéntico byte a byte al núcleo actual."); SetFamicomConsoleHover => "Model the Famicom's reset wiring instead of the front-loading NES's: the PPU is never held in reset, so it is past its ~29,658-cycle warm-up when the game starts, and the Reset button reaches only the CPU. Some Famicom carts (the 999-in-1 multicart) need it. Takes full effect from the next power cycle or ROM load. Off is byte-identical to today's core.", Some("Modela el cableado de reinicio de la Famicom en lugar del de la NES de carga frontal: la PPU nunca se mantiene en reinicio, así que ya ha terminado su calentamiento de ~29.658 ciclos cuando empieza el juego, y el botón Reset solo llega a la CPU. Algunos cartuchos de Famicom (el multicartucho 999-in-1) lo necesitan. Surte pleno efecto desde el siguiente apagado y encendido o carga de ROM. Desactivado es idéntico byte a byte al núcleo actual."); SetFastDotPathHover =>"Run the specialized straight-line handler for undisturbed visible background dots. Emits the identical frame either way (verified bit-for-bit every frame) and is ~11% faster on rendering-heavy games. Leave on unless you are diagnosing a suspected PPU difference.", Some("Ejecuta el manejador especializado en línea recta para los puntos de fondo visibles sin alteraciones. Produce el mismo fotograma en ambos casos (verificado bit a bit en cada fotograma) y es ~11% más rápido en juegos con mucho renderizado. Déjalo activado salvo que estés diagnosticando una posible diferencia de la PPU."); - SetEnhancementsNote => "Off-by-default enhancement modes. These are NEVER applied while accuracy tests / TAS replay / netplay run.", Some("Modos de mejora desactivados por defecto. NUNCA se aplican durante pruebas de precisión / reproducción de TAS / juego en red."); + SetEnhancementsNote => "Off-by-default enhancement modes. Accuracy tests never use them. Each one below says how it treats movies and netplay: some are recorded and must match between players, others are ignored there.", Some("Modos de mejora desactivados por defecto. Las pruebas de precisión nunca los usan. Cada uno indica abajo cómo trata las películas y el juego en red: algunos se graban y deben coincidir entre jugadores, otros se ignoran allí."); SetEnhSpriteInert => "Draws the sprites a scanline drops past eight, behind the eight the console shows. The game sees nothing different. Not on MMC2, MMC4 or a few boards whose pattern reads change the cartridge. Movies record it; netplay players must match.", Some("Dibuja los sprites que una línea descarta a partir del octavo, detrás de los ocho que muestra la consola. El juego no nota ninguna diferencia. No funciona en MMC2, MMC4 ni en algunas placas cuyas lecturas de patrones cambian el cartucho. Las películas lo graban; en el juego en red todos los jugadores deben coincidir."); SetEnhOverclockNote => "Adds idle scanlines after the visible frame to reduce slowdown in some games. Changes timing, so it is ignored while recording or playing a movie and during netplay.", Some("Agrega líneas de barrido inactivas después del fotograma visible para reducir la ralentización en algunos juegos. Cambia la temporización, por lo que se ignora al grabar o reproducir una película y durante el juego en red."); diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 44048b50d..9067f73a8 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -39,7 +39,7 @@ disposition under the v2.1.0 "Fathom" accuracy-remediation line | Optional OAM decay (F2.3) | Dynamic-RAM sprite decay when rendering stays off: per-8-byte-row 3000-CPU-cycle refresh model (Mesen2 `ReadSpriteRam`/`WriteSpriteRam`), rows refreshed by sprite-eval + `$2004`/DMA, decayed rows read `((a&3)==2)?a&0xE3:a` | No decay test ROM (Mesen2 code-level parity); NTSC/Dendy-only | **Landed on `main`** (Unreleased, next tag; F2.3) — **default-off** so the golden vectors / AccuracyCoin / commercial oracle (all decay-off) stay byte-identical; a real core feature (affects the framebuffer when ON), deterministic off `dot_counter`, save-state v7 tail (relative-age) | | NTSC generated palette (F1.4) | Optional composite-model base palette synthesized in-core (`rustynes_ppu::generate_base_palette`) in place of the hand table | `full_palette` visual corpus + Mesen2/ares baselines (visual only) | **Shipped** (v2.1.2) — **default-off**, golden-locked cross-target; select via Settings → Palette → "Generated NTSC". Default build byte-identical | | NTSC composite shader ladder (F2.2) | Display-only GPU post-passes: simplified blur (`Ntsc`) → LMP88959 composite → Bisqwit per-dot (`CompositeRt`) | No pass/fail ROM (visual only); `visual_regression` stays byte-identical with any filter active | **Shipped** (v2.1.2) — three-rung ladder verified end-to-end, live emulator-synced dot-crawl now on both composite passes (`Lmp88959` phase wired), palette↔pass split documented; no separable-kernel rung (LMP covers that tier). See `docs/frontend.md` | -| Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — netplay / TAS scoped out in dual mode per ADR 0032; dual save-state since v2.9.7, rewind and run-ahead since v3.1.0 (the ADR's amendments); wasm/mobile deferred | +| Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — netplay / TAS scoped out in dual mode per ADR 0032; dual save-state since v2.9.7, rewind and run-ahead since v3.1.0 (the ADR's amendments). The mobile bridge carries the cabinet since v2.9.7, with a screen switch rather than two screens; its on-device rows (run sheet T8-T10) are NOT RUN and the Swift half is uncompiled | | NSF non-60 Hz playback + NSFe | **Done** (F4.1/F4.2): the play-speed divider (`$6E-$6F`/`$78-$79`) is parsed and a non-standard rate (PAL 50 Hz / custom µs) drives `play` via a mapper cycle-timer IRQ (frame-IRQ-disabled); standard 60 Hz keeps the byte-identical vblank-NMI path. `NSFE` chunked container parsed (INFO/DATA/BANK/auth). | `nsf` unit + core integration tests | **Done** (F4.1/F4.2) | | FDS medium model (F4.3) | Byte-stream wire medium: gap / `$80` mark / block / **CRC-16-KERMIT** per block, with **per-block CRC re-emitted on write** (`resynth_block_crc`) and an opt-in **continuous belt-velocity head-seek** model (distance-proportional re-seek, default-off) replacing the fixed-cycle window | **CI-verifiable (synthetic):** `medium_write_verify` BIOS-free oracle — write via the register path, re-walk the wire, assert every block's CRC-16 + gap/mark framing round-trips (`fds::tests::synthetic_write_verify_*`). **Local-only:** the real-BIOS write-CRC path (BIOS recomputes CRC in its own RAM → `$4024`) needs a copyright `disksys.rom`, kept in gitignored `tests/roms/external/` and out of CI | **Shipped (v2.2.0 "Capstone")** — additive: default (model-off, non-writing) `.fds` run is **byte-identical**; new state round-trips the **v4** save-state tail. AccuracyCoin has no FDS ROM, so 141/141 is unaffected | | Famicom microphone ($4016.2) | Not modeled | Built-in controller-2 mic bit surfaced on `$4016` D2 (`Nes::set_microphone`); `famicom_microphone_drives_4016_bit2` bus unit test | **Shipped (v2.2.0)** — additive / default-off (mic released ⇒ `$4016` byte-identical); a `$4016`-only signal (never touches `$4017`). No pass/fail mic ROM exists (real-cart local test only) | diff --git a/docs/mobile-v2.9.3-run-sheet.md b/docs/mobile-v2.9.3-run-sheet.md index 3d7ef9c01..65184b8a9 100644 --- a/docs/mobile-v2.9.3-run-sheet.md +++ b/docs/mobile-v2.9.3-run-sheet.md @@ -196,6 +196,7 @@ diverges from the host's frames has a shell bug, not a core one. | G15 | Android | On a tablet or unfolded foldable, then a phone | Two-pane and compact layouts both correct; the image letterboxes at any aspect | NOT RUN | | G16 | Android | Fold and unfold mid-game; on Android TV, navigate with the D-pad | No restart on fold; every TV control reachable, and the app boots to its TV banner | NOT RUN | | G17 | Android | `foss` flavor: inspect the merged manifest and Settings | No `AD_ID` permission, no Play Services metadata, no Billing, Play Games or Cast surface (ADR 0025) | NOT RUN | +| G18 | Android | `play` flavor (`./gradlew :app:installPlayDebug`): boot a ROM, then open Settings | Installs and plays like `foss`; its Play-Services surface (Play Games, cloud save, Cast, in-app updates) appears, and a device without Play Services still boots and plays | NOT RUN | ## Android diff --git a/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv b/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv index 0dc4bd7ed..3f099ff02 100644 --- a/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv +++ b/tests/roms/AccuracyCoin/sub-tests/BUILD-PROVENANCE.tsv @@ -120,11 +120,13 @@ # offset, 0x10B5, 0x02 -> 0x03 (the `LDX #test` immediate; `LDY #suite` sits at # 0x10AB). # -# v3.1.0: `sprite-eval-misaligned-oam` settles on frame 211 (212 before). The -# misaligned-evaluation fixes make its tests 3, 6 and 7 pass on their own -# path; before, each failed and fell through the ROM's unpopped fail path, -# which ran a different number of cycles. Verdict unchanged (Pass); the -# generator's output differs from the old data in that one field only. +# v3.1.0: `sprite-eval-misaligned-oam` is REBUILT from upstream `f5f41dc2`, +# whose fail path exits instead of falling through to a pass. The rebuilt ROM +# settles on frame 175 with 18/6 (see its row). The legacy build it replaces +# settled on frame 211 after the misaligned-evaluation fixes (212 before): +# its tests 3, 6 and 7 then passed on their own path instead of failing into +# the unpopped fail path, which ran a different number of cycles. That legacy +# figure describes a ROM no longer in this directory. # # REGENERATE WITH THE GENERATOR, AND THE SCHEMA LINE BELOW COMES FROM IT: # From 5f7eac66e5f93ef218b0746041fd31f7281a66b8 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 04:56:10 -0400 Subject: [PATCH 14/21] refactor(netplay): name each older Sync magic instead of indexing the list Antigravity's review of #594 pointed at `magic == OLDER_SYNC_MAGICS[2]`: the index means "protocol 6", and that meaning lives only in the order of the array. Four places relied on the same convention ([0] = protocol 4, [1] = protocol 5, [2] = protocol 6): the epoch-bearing refusal in `check_sync`, both legacy-length decode arms in `from_bytes`, and two tests. Adding a protocol at the front of the list, or sorting it, would have re-pointed every one of them with no compile error, and the decode arms would then have accepted a 68-byte Sync under protocol 4's magic. Each magic is now an associated constant (`PROTOCOL_4_SYNC_MAGIC`, `PROTOCOL_5_SYNC_MAGIC`, `PROTOCOL_6_SYNC_MAGIC`, each documented with its version range and payload shape), `OLDER_SYNC_MAGICS` is built from them and kept for the membership test, and no indexed use remains (`rg 'OLDER_SYNC_MAGICS\['` finds nothing). The wire values are unchanged, so this is not a protocol change. Also from the same review: the Settings combo for the CPU overclock showed `config.enhancements.cpu_overclock.max(1)`, so a hand-edited config of 9 displayed "x9" while the core, which clamps in `Nes::set_cpu_overclock`, ran x4. The combo now clamps to 1..=MAX_CPU_OVERCLOCK the way the core does. The review's broader suggestion, validating the field at deserialization, was not taken: the frontend clamps at the use site (as `Overscan::clamped` does), and nothing unclamped reaches a record. HardwareOptions captures `nes.cpu_overclock()`, already clamped, and the `.rnm` reader refuses 0 or anything above 4. The accuracy-ledger Vs. DualSystem row also gains the browser half of Copilot's finding: since v2.9.7 the wasm-winit build runs the cabinet and presents both screens, while the wasm-canvas embed runs the main console only (CHANGELOG [2.9.7]). Verified: rustynes-netplay lib 102/102 and its integration tests; fmt; clippy -D warnings on rustynes-netplay and rustynes-frontend. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../src/debugger/settings_panel.rs | 7 +++- crates/rustynes-netplay/src/message.rs | 33 ++++++++++++++----- docs/accuracy-ledger.md | 2 +- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/rustynes-frontend/src/debugger/settings_panel.rs b/crates/rustynes-frontend/src/debugger/settings_panel.rs index c540c9ba3..efce27458 100644 --- a/crates/rustynes-frontend/src/debugger/settings_panel.rs +++ b/crates/rustynes-frontend/src/debugger/settings_panel.rs @@ -1997,7 +1997,12 @@ fn enhancements_section(ui: &mut egui::Ui, state: &mut SettingsPanelState, confi // config stays at its default. ui.horizontal(|ui| { ui.label(crate::t!(SetCpuOverclock)); - let current = config.enhancements.cpu_overclock.max(1); + // Clamped as the core clamps it, so a hand-edited config + // shows the multiplier that actually runs. + let current = config + .enhancements + .cpu_overclock + .clamp(1, rustynes_core::MAX_CPU_OVERCLOCK); let label = |k: u8| { if k == 1 { crate::t!(SetCpuOverclockOff).to_string() diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs index f035657dd..0357043b4 100644 --- a/crates/rustynes-netplay/src/message.rs +++ b/crates/rustynes-netplay/src/message.rs @@ -178,7 +178,7 @@ impl SessionIdentity { } else if NetMessage::OLDER_SYNC_MAGICS.contains(&magic) { // Protocol 6 carries its epoch (same layout as ours), so the reason // can name it; protocols 4 and 5 have none. - let theirs = (magic == NetMessage::OLDER_SYNC_MAGICS[2]).then_some(peer.epoch); + let theirs = (magic == NetMessage::PROTOCOL_6_SYNC_MAGIC).then_some(peer.epoch); SyncVerdict::Refuse(IdentityMismatch::Emulator { ours: self.epoch, theirs, @@ -341,8 +341,23 @@ impl NetMessage { /// `RustyNES`'s own earlier `Sync` magics: protocol 4 (`"RNES"`, v2.5.x to /// v2.9.7), protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9) and protocol 6 /// (`"RNE6"`, v3.0.0 and v3.0.1). Recognised so a peer on an older version - /// is refused with a reason ([`SessionIdentity::check_sync`]). - pub const OLDER_SYNC_MAGICS: [u32; 3] = [0x524E_4553, 0x524E_4535, 0x524E_4536]; + /// is refused with a reason ([`SessionIdentity::check_sync`]). Code that + /// needs one protocol names its constant below rather than an index, so + /// adding a protocol to this list cannot silently re-point a check. + pub const OLDER_SYNC_MAGICS: [u32; 3] = [ + Self::PROTOCOL_4_SYNC_MAGIC, + Self::PROTOCOL_5_SYNC_MAGIC, + Self::PROTOCOL_6_SYNC_MAGIC, + ]; + /// Protocol 4's `Sync` magic, `"RNES"` (v2.5.x to v2.9.7): a 36-byte + /// payload with no config hash and no epoch. + pub const PROTOCOL_4_SYNC_MAGIC: u32 = 0x524E_4553; + /// Protocol 5's `Sync` magic, `"RNE5"` (v2.9.8 and v2.9.9): a 68-byte + /// payload with both hashes and no epoch. + pub const PROTOCOL_5_SYNC_MAGIC: u32 = 0x524E_4535; + /// Protocol 6's `Sync` magic, `"RNE6"` (v3.0.0 and v3.0.1): today's layout, + /// epoch included, so a refusal can name the peer's epoch. + pub const PROTOCOL_6_SYNC_MAGIC: u32 = 0x524E_4536; // Tag bytes for the hand-rolled encoding. const TAG_INPUT: u8 = 0; @@ -507,7 +522,7 @@ impl NetMessage { // own length AND under its own magic, so the handshake can // refuse it with a reason (`check_sync`). Its hashes are // never compared; the epoch field is meaningless (0). - 68 if magic == Self::OLDER_SYNC_MAGICS[1] => Some(Self::Sync { + 68 if magic == Self::PROTOCOL_5_SYNC_MAGIC => Some(Self::Sync { magic, identity: SessionIdentity { epoch: 0, @@ -515,7 +530,7 @@ impl NetMessage { config_hash: rest.get(36..68)?.try_into().ok()?, }, }), - 36 if magic == Self::OLDER_SYNC_MAGICS[0] => Some(Self::Sync { + 36 if magic == Self::PROTOCOL_4_SYNC_MAGIC => Some(Self::Sync { magic, identity: SessionIdentity { epoch: 0, @@ -685,8 +700,8 @@ mod tests { fn an_older_rustynes_sync_is_refused_with_a_reason() { let ours = SessionIdentity::new([7u8; 32], [9u8; 32]); for (magic, payload) in [ - (NetMessage::OLDER_SYNC_MAGICS[1], 68usize), // protocol 5 - (NetMessage::OLDER_SYNC_MAGICS[0], 36usize), // protocol 4 + (NetMessage::PROTOCOL_5_SYNC_MAGIC, 68usize), + (NetMessage::PROTOCOL_4_SYNC_MAGIC, 36usize), ] { let mut bytes = vec![NetMessage::TAG_SYNC]; bytes.extend_from_slice(&magic.to_le_bytes()); @@ -727,7 +742,7 @@ mod tests { config_hash: [1u8; 32], }; let bytes = NetMessage::Sync { - magic: NetMessage::OLDER_SYNC_MAGICS[2], + magic: NetMessage::PROTOCOL_6_SYNC_MAGIC, identity: theirs, } .to_bytes(); @@ -741,7 +756,7 @@ mod tests { theirs: Some(2), }) ); - assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::OLDER_SYNC_MAGICS[2]); + assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::PROTOCOL_6_SYNC_MAGIC); } #[test] diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 9067f73a8..958dced8d 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -39,7 +39,7 @@ disposition under the v2.1.0 "Fathom" accuracy-remediation line | Optional OAM decay (F2.3) | Dynamic-RAM sprite decay when rendering stays off: per-8-byte-row 3000-CPU-cycle refresh model (Mesen2 `ReadSpriteRam`/`WriteSpriteRam`), rows refreshed by sprite-eval + `$2004`/DMA, decayed rows read `((a&3)==2)?a&0xE3:a` | No decay test ROM (Mesen2 code-level parity); NTSC/Dendy-only | **Landed on `main`** (Unreleased, next tag; F2.3) — **default-off** so the golden vectors / AccuracyCoin / commercial oracle (all decay-off) stay byte-identical; a real core feature (affects the framebuffer when ON), deterministic off `dot_counter`, save-state v7 tail (relative-age) | | NTSC generated palette (F1.4) | Optional composite-model base palette synthesized in-core (`rustynes_ppu::generate_base_palette`) in place of the hand table | `full_palette` visual corpus + Mesen2/ares baselines (visual only) | **Shipped** (v2.1.2) — **default-off**, golden-locked cross-target; select via Settings → Palette → "Generated NTSC". Default build byte-identical | | NTSC composite shader ladder (F2.2) | Display-only GPU post-passes: simplified blur (`Ntsc`) → LMP88959 composite → Bisqwit per-dot (`CompositeRt`) | No pass/fail ROM (visual only); `visual_regression` stays byte-identical with any filter active | **Shipped** (v2.1.2) — three-rung ladder verified end-to-end, live emulator-synced dot-crawl now on both composite passes (`Lmp88959` phase wired), palette↔pass split documented; no separable-kernel rung (LMP covers that tier). See `docs/frontend.md` | -| Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — netplay / TAS scoped out in dual mode per ADR 0032; dual save-state since v2.9.7, rewind and run-ahead since v3.1.0 (the ADR's amendments). The mobile bridge carries the cabinet since v2.9.7, with a screen switch rather than two screens; its on-device rows (run sheet T8-T10) are NOT RUN and the Swift half is uncompiled | +| Vs. `DualSystem` second screen | Core modeled + `sub_framebuffer()` exposed; desktop frontend now presents both screens (side-by-side / stacked), routes P1-P4 + coin, plays the main console's audio | Synth harness + boot of the 4 DualSystem titles (real-cabinet boot stays fixture-limited — see below) | **Shipped** (v2.1.2 F2.1, desktop) — netplay / TAS scoped out in dual mode per ADR 0032; dual save-state since v2.9.7, rewind and run-ahead since v3.1.0 (the ADR's amendments). Since v2.9.7 the browser's wasm-winit build runs the cabinet and presents both screens (the `wasm-canvas` embed runs the main console only; the browser run is a manual check), and the mobile bridge carries the cabinet since v2.9.7, with a screen switch rather than two screens; its on-device rows (run sheet T8-T10) are NOT RUN and the Swift half is uncompiled | | NSF non-60 Hz playback + NSFe | **Done** (F4.1/F4.2): the play-speed divider (`$6E-$6F`/`$78-$79`) is parsed and a non-standard rate (PAL 50 Hz / custom µs) drives `play` via a mapper cycle-timer IRQ (frame-IRQ-disabled); standard 60 Hz keeps the byte-identical vblank-NMI path. `NSFE` chunked container parsed (INFO/DATA/BANK/auth). | `nsf` unit + core integration tests | **Done** (F4.1/F4.2) | | FDS medium model (F4.3) | Byte-stream wire medium: gap / `$80` mark / block / **CRC-16-KERMIT** per block, with **per-block CRC re-emitted on write** (`resynth_block_crc`) and an opt-in **continuous belt-velocity head-seek** model (distance-proportional re-seek, default-off) replacing the fixed-cycle window | **CI-verifiable (synthetic):** `medium_write_verify` BIOS-free oracle — write via the register path, re-walk the wire, assert every block's CRC-16 + gap/mark framing round-trips (`fds::tests::synthetic_write_verify_*`). **Local-only:** the real-BIOS write-CRC path (BIOS recomputes CRC in its own RAM → `$4024`) needs a copyright `disksys.rom`, kept in gitignored `tests/roms/external/` and out of CI | **Shipped (v2.2.0 "Capstone")** — additive: default (model-off, non-writing) `.fds` run is **byte-identical**; new state round-trips the **v4** save-state tail. AccuracyCoin has no FDS ROM, so 141/141 is unaffected | | Famicom microphone ($4016.2) | Not modeled | Built-in controller-2 mic bit surfaced on `$4016` D2 (`Nes::set_microphone`); `famicom_microphone_drives_4016_bit2` bus unit test | **Shipped (v2.2.0)** — additive / default-off (mic released ⇒ `$4016` byte-identical); a `$4016`-only signal (never touches `$4017`). No pass/fail mic ROM exists (real-cart local test only) | From b398f1af995b9ec4c50bb7c2daf6c1e956a4c5e7 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:09:05 -0400 Subject: [PATCH 15/21] fix: apply the new options on the Vs. cabinet; keep the MMC3 setting on restore Two defects CodeRabbit found in the release's code (review slice #595 of #594). Both reproduced red before the fix. 1. The CPU overclock and the sprite-limit option did nothing on a Vs. DualSystem cabinet. `produce_frame` pushes both into the console each frame; `produce_dual_frame` pushed neither, and `configure_console` deliberately leaves them out, so with a cabinet loaded both settings were shown, saved and inert. `produce_dual_frame` now applies them to the main AND the sub console. That is coherent for the overclock because the cabinet locksteps its consoles by CPU cycle count (`VsDualSystem::run_frame` keeps `main.cycle()` within 5 of `sub.cycle()`), so the same multiplier on both keeps the gap meaningful; its 150,000-cycle frame guard still covers x4 (PAL x4 is about 133,000). The single path's movie-idle condition has no counterpart: no movie or netplay session runs on a cabinet (ADR 0032). The extra-scanline overclock is still not applied on a cabinet; that predates v3.1.0 and is left as it was. Test `a_cabinet_runs_both_consoles_with_the_enhancement_options` (emu.rs): failed before the fix; mutant dropping the sub console's call fails with "sub console overclock". 2. Restoring a state could change the running MMC3 IRQ revision behind the setting's back. The MMC3's MAP section carries its live `revision`, and `SystemBus::restore` installed it without re-applying the configured override, so a state saved under the alternate override and loaded with none kept running the alternate chip while `mmc3_revision_override()` reported `None`, and the reverse. Movies, rewind, run-ahead and rollback all restore through this path. Restore now re-applies the configured override after MAP (`None` returns the board to its header's revision; a no-op on every other board): the revision is configuration, as the override's own docs already said. Test `a_restore_keeps_the_configured_mmc3_revision` (mmc3.rs) checks both directions; it failed before the fix with "a state saved under the override must not bring it back: MMC3 (Nec)". MMC3 suite 21/21. Verified: rustynes-core lib 253/253, rustynes-frontend lib 684/684, mmc3 21/21; fmt; clippy -D warnings for the workspace, the full, retroachievements and scripting+hd-pack feature sets and both wasm32 configurations; rustdoc -D warnings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 9 ++-- crates/rustynes-core/src/bus.rs | 8 ++++ crates/rustynes-frontend/src/emu.rs | 53 ++++++++++++++++++++++ crates/rustynes-test-harness/tests/mmc3.rs | 35 ++++++++++++++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d68f4d1..a000128cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,7 +96,9 @@ run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** multiplier is exact on every region: a CPU cycle is 12 master clocks on NTSC but 16 on PAL and 15 on Dendy, which do not divide by 3 or 4, so the overclocked cycle lengths alternate to keep the average exact (a review of - the release PR found PAL x3 running at x3.2 and Dendy x4 at x5). + the release PR found PAL x3 running at x3.2 and Dendy x4 at x5). It and the + sprite-limit option below apply to both consoles of a Vs. DualSystem cabinet + (the same review found the cabinet ignored both). - **"Disable 8-sprite-per-scanline limit" now works** (`T-SPRITE-LIMIT`; it was shown and saved but inert). It draws the dropped sprites behind the eight the console shows, and changes nothing the game can see: the overflow flag, the @@ -110,7 +112,8 @@ run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** - **MMC3 IRQ revision setting** (`T-MMC3-NEC-OVERRIDE`, Settings > Emulation): run any mapper-4 game on the Sharp or the alternate (MMC3A / NEC) chip, for dumps whose header cannot say which. blargg's `mmc3_test_2/6-MMC3_alt` passes - under the alternate setting. Carried in movies and netplay. + under the alternate setting. Carried in movies and netplay. A save state + never changes which revision runs: loading one keeps the current setting. - **Rewind and run-ahead on the Vs. DualSystem cabinet** (`T-PS-dual-runahead`, ADR 0032 amended): both work in two-screen mode, on the whole cabinet, so the two consoles never fall out of step. A step back restores both screens @@ -166,7 +169,7 @@ run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** ### Verification -- `cargo test --workspace --features test-roms --release`: **3,262 passed, 0 +- `cargo test --workspace --features test-roms --release`: **3,263 passed, 0 failed, 11 ignored**. The epoch fingerprint gate passes at epoch 3, now recorded as the released epoch. - AccuracyCoin **146/146** at upstream `f5f41dc2`; `nestest` 0-diff. diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 2692d7aa2..da2c079ba 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -3202,6 +3202,14 @@ impl SystemBus { if !saw_map { return Err(SnapshotError::MissingSection("MAP ".into())); } + // v3.1.0 (PR #594 review): the MMC3's MAP section carries its LIVE + // IRQ revision, which is configuration rather than console state, so + // re-apply the configured override (`None` = the header's). Without + // this a state saved under the override and loaded without it kept + // running the alternate revision while `mmc3_revision_override()` + // reported `None`, and the reverse. A no-op on every other board. + self.mapper + .set_mmc3_revision_override(self.mmc3_revision_override); // RW-0 fix: under R1, `dmc_driven_externally` is NOT serialized (it is // build configuration, not emulated state), so after `apu.restore` it // reverts to the `Apu::new` default (`false`), which STOPS `put_cycle` diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 5db894a6f..8f2041eff 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -1949,9 +1949,27 @@ impl EmuCore { self.vs_coin_frames -= 1; self.vs_coin_frames == 0 }; + // v3.1.0 — the CPU overclock and the sprite-limit option reach BOTH + // consoles, as `produce_frame` applies them to the one. The cabinet + // locksteps its consoles by CPU cycle count (`VsDualSystem::run_frame`), + // so the same multiplier on both keeps that gap meaningful. No movie + // or netplay session runs on a cabinet (ADR 0032), so the single + // path's movie-idle condition has no counterpart here. + let want_cpu = self.cpu_overclock.max(1); + let want_sprites = self.disable_sprite_limit; let Some(dual) = self.dual.as_mut() else { return; }; + let apply = |nes: &mut rustynes_core::Nes| { + if nes.cpu_overclock() != want_cpu { + nes.set_cpu_overclock(want_cpu); + } + if nes.sprite_limit_disabled() != want_sprites { + nes.set_sprite_limit_disabled(want_sprites); + } + }; + apply(dual.main_mut()); + apply(dual.sub_mut()); if clear_coin { dual.clear_coin(); } @@ -2859,6 +2877,41 @@ mod tests { ); } + /// v3.1.0 (PR #594 review) — the CPU overclock and the sprite-limit option + /// reach BOTH cabinet consoles. `produce_dual_frame` applied neither, so on + /// a Vs. `DualSystem` cabinet both settings silently did nothing. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_cabinet_runs_both_consoles_with_the_enhancement_options() { + let rom = crate::runahead::tests::flashing_cabinet(); + let mut sinks = FrameSinks { + audio: None, + #[cfg(feature = "retroachievements")] + ra: None, + }; + let mut core = EmuCore::new(); + core.set_dual(Box::new( + rustynes_core::VsDualSystem::from_rom(&rom).unwrap(), + )); + core.cpu_overclock = 3; + core.disable_sprite_limit = true; + core.produce_one_frame(&quiet_inputs(), &mut sinks); + let dual = core.dual.as_ref().unwrap(); + for (name, nes) in [("main", dual.main()), ("sub", dual.sub())] { + assert_eq!(nes.cpu_overclock(), 3, "{name} console overclock"); + assert!(nes.sprite_limit_disabled(), "{name} console sprite limit"); + } + // Back to stock: `0` and `1` both mean x1. + core.cpu_overclock = 0; + core.disable_sprite_limit = false; + core.produce_one_frame(&quiet_inputs(), &mut sinks); + let dual = core.dual.as_ref().unwrap(); + for nes in [dual.main(), dual.sub()] { + assert_eq!(nes.cpu_overclock(), 1); + assert!(!nes.sprite_limit_disabled()); + } + } + /// v2.9.7 (`T-PS-dual-savestate`) — a Vs. `DualSystem` cabinet saves and /// restores both consoles, byte for byte. A single-console blob and a /// cabinet blob are each refused by the other kind, and nothing restores diff --git a/crates/rustynes-test-harness/tests/mmc3.rs b/crates/rustynes-test-harness/tests/mmc3.rs index e2e0e2982..6c16484ce 100644 --- a/crates/rustynes-test-harness/tests/mmc3.rs +++ b/crates/rustynes-test-harness/tests/mmc3.rs @@ -189,6 +189,41 @@ fn the_mmc3_override_survives_a_power_cycle_and_clears() { ); } +/// v3.1.0 (PR #594 review): the override is configuration, so restoring a +/// state never changes which revision runs. The MMC3's live revision travels +/// in its MAP section, and until this fix a restore installed the SAVED one: +/// a state saved under the override and loaded without it kept running the +/// alternate revision while `mmc3_revision_override()` reported `None`, and +/// the reverse. +#[test] +fn a_restore_keeps_the_configured_mmc3_revision() { + use rustynes_core::rustynes_mappers::Mmc3Revision; + let path = rom_path("blargg/mmc3_test_2/5-MMC3.nes"); + let bytes = std::fs::read(&path).expect("read"); + let mut nes = rustynes_core::Nes::from_rom(&bytes).expect("parse"); + + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)); + let under_override = nes.snapshot(); + nes.set_mmc3_revision_override(None); + let at_header = nes.snapshot(); + + nes.restore(&under_override).expect("restore"); + assert_eq!(nes.mmc3_revision_override(), None); + assert!( + nes.mapper_info().name.contains("Sharp"), + "a state saved under the override must not bring it back: {}", + nes.mapper_info().name + ); + + nes.set_mmc3_revision_override(Some(Mmc3Revision::Nec)); + nes.restore(&at_header).expect("restore"); + assert!( + nes.mapper_info().name.contains("Nec"), + "a state saved at the header's revision must not drop the override: {}", + nes.mapper_info().name + ); +} + #[test] #[ignore = "by-design fail at the default: sub-ROM 6 is the alternate MMC3 IRQ revision; \ the default is Sharp (sub-ROM 5). Runs under the override in \ From 8af50dc0b91f518a7b62f2a2ba9ee7dbef078ed8 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:09:41 -0400 Subject: [PATCH 16/21] fix(test): let the epoch gate bless a new probe; close a catalog-row blind spot From CodeRabbit's review of slice #595 (of #594): two gate defects and four stale docs. Epoch fingerprint gate. A probe added to the panel after a release has no row in the table, and the gate counted every row it could not find as moved output, which may only happen under a raised EMULATION_EPOCH. So adding a probe at the released epoch was refused even though the core had not changed. This is not hypothetical: 5e99b073, which repointed two probes at tracked ROMs, had to lower `last_release_epoch` by hand to 2 to bless the table and then restore it. `classify` now separates the three cases by (rom, frames): an exact row is Unchanged; the same probe with different output is Changed (the epoch rule applies); no row for the probe is New (blessable at any epoch, and an unblessed new probe still fails with its own message). Unit test `a_new_probe_is_not_a_moved_output`; mutant forcing every unmatched row to Changed fails it with `left: Changed, right: New`. AccuracyCoin catalog extractor. Its completeness check only recognised a row macro whose first argument is a quoted string, so a future token-first row (`tblf3 str_Name, $FF, result_X, ...`) would have been skipped by both the parser and the check, and every later index in its suite would shift, which builds sub-test ROMs that run the wrong test. RE_ANY_ROW now keys on the macro family (`table`, `tbl*`), not on the argument; the family is named because 6502 mnemonics starting with `t` (`tax`, `tay`, `tsx`, `txa`, `txs`, `tya`) must not match. New self-test case (9 cases). On the pinned upstream source (f5f41dc2) the catalog is byte-identical to the committed SOURCE_CATALOG.tsv and derive_indices validates every recorded mapping, with output identical to HEAD's script. (The ~/.cache source checkout is the older 46199ae4, which lacks suite 14 test 6 and fails validation under both versions; it is not what the catalog is built from.) Docs corrected against the code: - `Nes::set_cpu_overclock` still described the integer division 11c96b9b removed ("x3 on PAL is x3.2"); it now gives the exact split. - `Nes::set_mmc3_revision_override` said the alternate revision asserts only on a 1 -> 0 decrement; since v3.1.0 it also asserts on a `$C001` reload to 0, even with the counter already 0 (`clock_irq` path 1). - snapshot_schema_audit described `cpu_div_effective` as `cpu_div_cached / cpu_overclock`. - `RamResultSummary.total` said 151; it is `scored_len()`, 146. - `fetch_extra_sprites` now says its raw `oam` walk bypasses the OAM-decay read hook on purpose, so the display option cannot refresh a DRAM row. Verified: epoch_fingerprint 2/2, snapshot_schema_audit 9/9, extract_catalog self-test 9/9; fmt; clippy -D warnings (workspace and feature sets); rustdoc -D warnings. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/nes.rs | 16 ++-- crates/rustynes-ppu/src/ppu.rs | 5 +- .../src/accuracy_coin_catalog.rs | 5 +- .../tests/epoch_fingerprint.rs | 77 ++++++++++++++++--- .../tests/snapshot_schema_audit.rs | 4 +- scripts/accuracycoin-build/extract_catalog.py | 27 ++++++- 6 files changed, 112 insertions(+), 22 deletions(-) diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 56fb9d9b4..c578e72a9 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -3044,8 +3044,11 @@ impl Nes { /// the CPU runs `k` times faster against an unchanged PPU, so a game gets /// `k` times the CPU time per frame. /// - /// It divides the region's master-clock CPU divider (NTSC 12 -> 6 / 4 / 3; - /// PAL 16 and Dendy 15 round down, so `x3` on PAL is x3.2). The APU, the + /// It splits the region's master-clock CPU divider into `k` cycles whose + /// lengths sum to exactly one stock cycle (NTSC 12 -> 6 / 4 / 3; PAL 16 at + /// `x3` is 5, 5, 6 and Dendy 15 at `x4` is 3, 4, 4, 4), so the multiplier is + /// exact on every region. Until the v3.1.0 review it divided with integer + /// division, which made PAL `x3` run x3.2 and Dendy `x4` x5. The APU, the /// DMC, every mapper's CPU-cycle hook (the VRC / FME-7 / N163 IRQ /// counters) and the PPU's open-bus and post-reset timers stay at the /// STOCK rate, so the pitch, the music tempo and the cycle-timed raster @@ -3105,9 +3108,12 @@ impl Nes { /// a chosen IRQ revision, or `None` for the one its header selects. /// /// The MMC3's two IRQ behaviours are mutually exclusive: the Sharp MMC3B - /// / MMC3C (the default) asserts IRQ when the counter is reloaded to 0, the - /// MMC3A and non-Sharp MMC3B (`Mmc3Revision::Nec`) only on a 1 -> 0 - /// decrement. A NES 2.0 header can say which (submapper 4); an iNES 1.0 + /// / MMC3C (the default) asserts IRQ whenever a clock leaves the counter at + /// 0 with IRQs enabled, so a latch of 0 fires every scanline. The MMC3A + /// and non-Sharp MMC3B (`Mmc3Revision::Nec`) assert on a 1 -> 0 decrement + /// and on a `$C001` reload to 0 (one IRQ per `$C001` write while `$C000` + /// is 0, even if the counter was already 0), but not when the counter, + /// already 0, reloads 0 by itself. A NES 2.0 header can say which (submapper 4); an iNES 1.0 /// dump cannot, so this override is how a player runs a game, or blargg's /// `mmc3_test_2/6-MMC3_alt`, on the other chip. The default (`None`) is /// unchanged. Configuration, re-applied when a power cycle rebuilds the diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 2427f7965..75bd20478 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -6460,7 +6460,10 @@ impl Ppu { /// starts at OAMADDR 0, as it does on every normally rendered line). A line /// whose evaluation starts misaligned (a mid-frame `$2003` write, a test /// construction) can draw a slightly different set; it is a display - /// enhancement, not hardware behaviour. + /// enhancement, not hardware behaviour. The walk reads `oam` directly and + /// deliberately bypasses the optional OAM-decay read hook, so drawing the + /// extra sprites can never refresh a decaying DRAM row the game could + /// later observe. fn fetch_extra_sprites(&mut self, bus: &mut B, next_line: i16, height: i16) { self.spr_extra_count = 0; if !self.sprite_limit_disabled diff --git a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs index 093abe9e1..0bdbe4c14 100644 --- a/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs +++ b/crates/rustynes-test-harness/src/accuracy_coin_catalog.rs @@ -300,8 +300,9 @@ pub fn decode_results(ram: &[u8]) -> Option> { /// counts here and the named list there cannot disagree. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct RamResultSummary { - /// Total number of catalog entries (always 151 if the catalog is - /// fully loaded). + /// Number of SCORED catalog entries, [`scored_len`]: 146 at upstream + /// `f5f41dc2`. The catalog holds 151 rows; the five sentinel rows that + /// share [`RESULT_DRAW_TEST`] are not scored and not counted here. pub total: u32, /// Tests that wrote `$01` (clean pass). pub pass: u32, diff --git a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs index fc49d66c7..d448217a1 100644 --- a/crates/rustynes-test-harness/tests/epoch_fingerprint.rs +++ b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs @@ -246,6 +246,50 @@ fn render_table(last_release_epoch: u32, rows: &[Fingerprint]) -> String { out } +/// How one panel probe compares with the committed table. +#[derive(Debug, PartialEq, Eq)] +enum Verdict { + /// The table holds this exact row. + Unchanged, + /// The table holds this ROM at this frame count with different output: + /// emulation moved, which needs a raised epoch. + Changed, + /// The table has no row for this ROM at this frame count: a probe added + /// to the panel, blessable at any epoch. + New, +} + +fn classify(now: &Fingerprint, rows: &[Fingerprint]) -> Verdict { + if rows.contains(now) { + Verdict::Unchanged + } else if rows + .iter() + .any(|r| r.rom == now.rom && r.frames == now.frames) + { + Verdict::Changed + } else { + Verdict::New + } +} + +#[test] +fn a_new_probe_is_not_a_moved_output() { + let row = |rom: &str, frames: u32, fb: u64| Fingerprint { + rom: rom.into(), + frames, + framebuffer: fb, + audio: 1, + ram: 2, + cycles: 3, + }; + let table = [row("a.nes", 60, 7)]; + assert_eq!(classify(&row("a.nes", 60, 7), &table), Verdict::Unchanged); + assert_eq!(classify(&row("a.nes", 60, 8), &table), Verdict::Changed); + assert_eq!(classify(&row("b.nes", 60, 7), &table), Verdict::New); + // A new frame count for a known ROM is a new probe, not a moved one. + assert_eq!(classify(&row("a.nes", 90, 8), &table), Verdict::New); +} + #[test] fn output_moves_only_with_the_emulation_epoch() { let now: Vec = PANEL.iter().map(measure).collect(); @@ -260,15 +304,23 @@ fn output_moves_only_with_the_emulation_epoch() { table.last_release_epoch ); - let moved: Vec = now - .iter() - .zip(PANEL) - .filter(|(f, _)| !table.rows.contains(f)) - .map(|(f, p)| format!(" {} ({}): now {}", f.rom, p.reach, f.row())) - .collect(); + let mut moved: Vec = Vec::new(); + let mut added: Vec = Vec::new(); + for (f, p) in now.iter().zip(PANEL) { + let line = format!(" {} ({}): now {}", f.rom, p.reach, f.row()); + match classify(f, &table.rows) { + Verdict::Unchanged => {} + Verdict::Changed => moved.push(line), + Verdict::New => added.push(line), + } + } // The rule (ADR 0045): output may differ from the LAST RELEASE only under // a raised epoch. Within one release, a second change after the rise - // needs a re-bless, not a second rise. + // needs a re-bless, not a second rise. A probe the table has never seen + // (a ROM, or a frame count, added to the panel) is not a moved output, so + // it can be blessed at the released epoch (PR #594 review: until then + // adding a probe after a release demanded an epoch rise, and v3.1.0's + // panel change had to lower `last_release_epoch` by hand to bless one). let raised = EMULATION_EPOCH > table.last_release_epoch; assert!( @@ -285,12 +337,19 @@ fn output_moves_only_with_the_emulation_epoch() { fs::write(table_path(), render_table(table.last_release_epoch, &now)) .expect("write epoch_fingerprint.tsv"); eprintln!( - "blessed epoch_fingerprint.tsv at epoch {EMULATION_EPOCH} ({} rows moved)", - moved.len() + "blessed epoch_fingerprint.tsv at epoch {EMULATION_EPOCH} ({} rows moved, {} new)", + moved.len(), + added.len() ); return; } + assert!( + added.is_empty(), + "the panel has probes the table does not record; bless them with \ + RUSTYNES_BLESS_EPOCH_FINGERPRINT=1 (no epoch rise needed). New:\n{}", + added.join("\n") + ); assert!( moved.is_empty(), "emulated output changed under epoch {EMULATION_EPOCH}, which is already \ diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index 92c741260..e9e61775d 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -488,7 +488,9 @@ const CHIPS: &[Chip] = &[ ), ( "cpu_div_effective", - "derived: `cpu_div_cached / cpu_overclock`, recomputed when the overclock is set", + "derived: `overclock_cycle_len(cpu_div_cached, cpu_overclock, overclock_phase)`, \ + the phase's share of one stock cycle (the `k` shares sum to the divider), \ + recomputed whenever the overclock or the phase changes", ), ( "stock_step", diff --git a/scripts/accuracycoin-build/extract_catalog.py b/scripts/accuracycoin-build/extract_catalog.py index 765a61acc..b264df538 100755 --- a/scripts/accuracycoin-build/extract_catalog.py +++ b/scripts/accuracycoin-build/extract_catalog.py @@ -62,9 +62,14 @@ r"\s*\$FF\s*,\s*(result_[A-Za-z0-9_]+)\s*,", re.M, ) -# Any line opening with a row-like macro (`table`, `tblf1`, `tblfN`, ...). -# Used only to prove every such line was read by one of the patterns above. -RE_ANY_ROW = re.compile(r'^\s*(t[a-z]*[0-9]*)\s+"', re.M) +# Any line opening with a row macro (`table`, `tblf1`, `tblfN`, ...), whatever +# its first argument is. Used only to prove every such line was read by one of +# the patterns above. Keyed on the macro FAMILY rather than on a quoted first +# argument (PR #594 review): a future `tblf3 str_Name, $FF, result_X, ...` row +# would have slipped past a quote-anchored check and silently shifted every +# later index. `table`/`tbl` because the 6502 mnemonics that start with `t` +# (`tax`, `tay`, `tsx`, `txa`, `txs`, `tya`) must not match. +RE_ANY_ROW = re.compile(r"^\s*(table|tbl[a-z0-9]*)\s+\S", re.M) RE_TOKEN_DEF = re.compile(r"^(str_[A-Za-z0-9_]+)\s*=\s*\$([0-9A-Fa-f]+)", re.M) RE_SPECIAL_STRINGS = re.compile( r'^PrintTextSpecialStrings:\s*\n((?:\s*\.byte\s+"[^"]*"\s*\n?)+)', re.M @@ -256,6 +261,20 @@ def self_test() -> int: else: raise AssertionError("an unknown row macro was dropped silently") + # ...including one whose FIRST argument is a token rather than a string, + # which the detector used to require (PR #594 review). + tokenfirst = SELF_TEST_ASM.replace( + '\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma', + '\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma\n' + "\ttblf3 str_ZeroPage, $FF, result_Alpha, TEST_Alpha", + ) + try: + parse(tokenfirst) + except SystemExit as exc: + assert "tblf3" in str(exc), exc + else: + raise AssertionError("a token-first row macro was dropped silently") + # ...and a suite that yields no rows at all must abort. empty = SELF_TEST_ASM.replace('\ttable "Gamma Test", $FF, result_Gamma, TEST_Gamma\n', "") try: @@ -274,7 +293,7 @@ def self_test() -> int: else: raise AssertionError("an unknown word token was accepted") - print("extract_catalog self-test: ok (8 cases)") + print("extract_catalog self-test: ok (9 cases)") return 0 From c749c438716603bddf399f36058f63ac997aa6dd Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:27:23 -0400 Subject: [PATCH 17/21] test(core): pin that switching the overclock off is an ordinary frame Antigravity's review of #594 (an archived round) reported as BLOCKING that returning the CPU overclock to x1 hangs the emulator: under the overclock the APU runs on its own stock-rate counter (`apu_cycle`) while the CPU's `cycle` runs k times faster, so after a long session they are tens of millions of cycles apart, and x1 hands the APU `cycle` again. The review read that as a catch-up loop that stalls the thread and floods the audio buffer. It is not one, and this commit shows it rather than arguing it. `Apu::set_canonical_cycle` is a plain assignment; the APU uses the counter only for its put/get parity and a pending IRQ-flag-clear deadline, never as a distance to cover. New test `switching_back_to_stock_after_a_long_overclock_is_an_ordinary_frame` runs 600 frames at x4 (about 54 million cycles of gap), switches to x1, and requires each of the next ten frames to produce the stock CPU-cycle and audio-sample counts (within 2). It passes. The mutant is the defect the review described: a loop in `cpu_clock` that walks the APU through the whole gap at x1. It fails the test with "frame 0 after switching back: 1319359 audio samples, stock is 733". What the switch does cost, recorded rather than fixed: the counter's jump can flip the APU's put/get parity once at the switch (switching ON keeps it, by re-basing `apu_cycle` on `cycle`). That is a one-cycle phase change at a moment no real console has, deterministic, and of the same kind as changing the option at all. Also from the same review: the `HardwareOptions::cpu_overclock` field doc now says what happens outside 1..=MAX_CPU_OVERCLOCK (decoding refuses, applying clamps). Not taken: making the field private (the struct is `#[non_exhaustive]` with public fields by design since v3.0.0), and the nitpick that the snapshot's phase error omits the value (it already reads "CPU-overclock phase {overclock_phase} out of range"). Verified: cpu_overclock 8/8; fmt; clippy -D warnings on rustynes-core and the harness with test-roms; rustdoc -D warnings on rustynes-core. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/hardware_options.rs | 5 ++- .../tests/cpu_overclock.rs | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index f87db2155..95467e71a 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -136,7 +136,10 @@ pub struct HardwareOptions { /// The extra-vblank-scanline overclock ([`Nes::set_extra_scanlines`]). pub extra_scanlines: u16, /// The CPU-multiplier overclock, `1..=MAX_CPU_OVERCLOCK` - /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. + /// ([`Nes::set_cpu_overclock`]); `1` is stock. v3.1.0. A value outside + /// that range never reaches the core as written: decoding a record + /// refuses it, and applying one clamps it (`0` to `1`, anything above to + /// the maximum), as `Nes::set_cpu_overclock` does. pub cpu_overclock: u8, /// Draw the sprites beyond the eighth on a scanline /// ([`Nes::set_sprite_limit_disabled`]); render-only. v3.1.0. diff --git a/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs index bbba2c0e7..457131599 100644 --- a/crates/rustynes-test-harness/tests/cpu_overclock.rs +++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs @@ -256,3 +256,43 @@ fn the_multiplier_is_exact_on_pal_and_dendy_too() { } } } + +/// Switching the overclock back OFF after a long run is an ordinary frame. +/// +/// Under the overclock the APU runs on its own stock-rate counter +/// (`apu_cycle`) while the CPU's counter (`cycle`) runs `k` times faster, so +/// by the time the player switches back to `x1` the two are tens of millions +/// of cycles apart, and `x1` hands the APU the CPU's counter again. A review +/// of the release PR (#594) read that as a catch-up loop that would stall the +/// emulator and flood the audio buffer. It is not one: the APU takes the +/// counter by assignment (`Apu::set_canonical_cycle`) and uses it for its +/// put/get parity and a pending IRQ-flag clear, never as a distance to +/// cover. This pins that: after 600 frames at `x4`, each of the next frames +/// at `x1` produces the stock number of CPU cycles and audio samples. +#[test] +fn switching_back_to_stock_after_a_long_overclock_is_an_ordinary_frame() { + let (stock_cycles, stock_samples) = measure(1, 10); + let mut nes = boot_rom(AUDIBLE_ROM); + nes.set_cpu_overclock(4); + for _ in 0..600 { + nes.run_frame(); + let _ = nes.drain_audio(); + } + nes.set_cpu_overclock(1); + for frame in 0..10 { + let start = nes.cycle(); + nes.run_frame(); + let cycles = nes.cycle() - start; + let samples = nes.drain_audio().len(); + assert!( + cycles.abs_diff(stock_cycles / 10) <= 2, + "frame {frame} after switching back: {cycles} CPU cycles, stock is {}", + stock_cycles / 10 + ); + assert!( + samples.abs_diff(stock_samples / 10) <= 2, + "frame {frame} after switching back: {samples} audio samples, stock is {}", + stock_samples / 10 + ); + } +} From c95a8d102693de36f246d30f58aeca30d9cfa61d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 06:44:47 -0400 Subject: [PATCH 18/21] docs: correct sixteen stale v3.1.0 records; gate drafting placeholders CodeRabbit's review of slice #596 (the docs half of #594) found seventeen statements the release left stale. Each was checked against the tree; sixteen were real and are fixed here, one was a cross-slice misread (answered on the PR). The most serious was not prose at all: - **CHANGELOG [3.1.0] shipped `- The MiSTer core: LADDER-FILL.`**, a drafting placeholder, in the release PR. It now gives the ladder result (on-die 212 / 0 / 1, off-die 213 / 0 / 1, one frozen-tree run each; the core matches all 146 AccuracyCoin entries). Nothing caught it but a reviewer, so `release_notes_render_audit.rs` gains `no_fill_placeholder_reaches_a_release_body`: every `.github/release-notes/*.md` and the CHANGELOG must contain no upper-case `WORD-FILL` token. Its first run caught the one other placeholder in the tree, `BITSTREAM-FILL` in the still-uncommitted v3.1.0 notes, which stays until the bitstreams are measured. A scanner unit test pins what counts (`LADDER-FILL` yes; `pre-fill`, `Fill-in`, `X-FILLER`, a bare `-FILL` no). The rest, by file: - README: the AccuracyCoin badge read 144/144 under the 146/146 headline. - STATUS, compatibility.md: 144/144 (and 141/141) stated without saying they included the masked `Misaligned OAM behavior` failure; now bounded to their releases and marked overstated, with 146/146 since v3.1.0. - ROADMAP: v3.0.1 still "in progress" under a v3.1.0 current-release anchor. - SECURITY: 3.0.x was "the current line"; 3.1.x is, and 3.0.x is Partial. - to-dos/README: the release line ended at v3.0.0 as "the current release". - v3.1.0 plan: row 2 gave `last_release_epoch` 2 without saying the cut set it to 3; row 10 said G1-G17 after the review added G18; DOC-06 said libretro/docs#1215 open. - libretro UPSTREAM_SYNC: docs#1215 MERGED 2026-10-08 08:55 UTC (`4a0c09f236`, checked with `gh pr view`), not open. - docs/agents/ci-and-release: named `.rnm` format 5 / protocol 6 as current; format 6 / protocol 7 (`"RNE7"`) since v3.1.0, and a probe newly added to the epoch panel now blesses at any epoch (8af50dc0). - mappers.md gotcha 2 called the Sharp default "MMC3A" and put MMC3B on submapper 1 (that is the MMC6; the alternate revision is submapper 4). It now states the rule `clock_irq` implements. The open-questions answer names the public owner, `Nes::set_mmc3_revision_override`. - ppu-2c02.md: "the fourth byte copied is the X position" is false for a walk starting at m = 3, where the fourth byte is slot n+1's attribute byte; it is evaluated AS X, which is what the rule needs to say. - mobile run sheet: a device-vs-host frame mismatch is attributed to the mobile integration only given the same ROM, initial state and inputs. - DEFERRED: the CPU-multiplier overclock item still `[ ]` and scheduled for v3.1.0; now `[x]` with what shipped. The old "141/141 on the shipping core" sentence is marked historical. Not changed: tests/roms/AccuracyCoin/README.md's claim that the extractor aborts on an unreadable row-shaped line and on an empty suite is accurate. The review read a version of `parse()` that slice did not carry; `extract_catalog.py` compares every `RE_ANY_ROW` candidate against the rows parsed and rejects a suite that yields none. Verified: release_notes_render_audit 3/4 here, the fourth failing only on the uncommitted notes file as intended; markdownlint via pre-commit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 5 +- README.md | 2 +- SECURITY.md | 3 +- .../tests/release_notes_render_audit.rs | 68 +++++++++++++++++++ docs/STATUS.md | 2 +- docs/agents/ci-and-release.md | 2 +- docs/compatibility.md | 2 +- docs/libretro/UPSTREAM_SYNC.md | 12 ++-- docs/mappers.md | 4 +- docs/mobile-v2.9.3-run-sheet.md | 7 +- docs/ppu-2c02.md | 4 +- to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md | 15 ++-- to-dos/README.md | 3 +- to-dos/ROADMAP.md | 4 +- to-dos/plans/v3.1.0-plan.md | 6 +- 15 files changed, 112 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a000128cb..90b652870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -178,7 +178,10 @@ run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** *Millionaire* (Sachen, mapper 146), which the game database marks PAL, at one checkpoint, from the PAL emphasis fix. It was attributed by running that ROM alone on each v3.1.0 commit, and re-blessed. -- The MiSTer core: LADDER-FILL. +- The MiSTer core: one frozen-tree run of each co-simulation ladder at an + oracle pin on this branch, nothing skipped: on-die 212 passed, 0 failed, + 1 expected failure; off-die 213 / 0 / 1. The core matches the emulator on + all 146 AccuracyCoin entries. ## [3.0.1] - 2026-10-07 - "Mortar" (the open items closed, one game's graphics fixed, the last MMC3 rule exception tested in the MiSTer core, Rust 1.99 everywhere, every unanswered bot review answered, and a roadmap to v4.0.0) diff --git a/README.md b/README.md index 97a755a11..27e1b8884 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

Build Status License: GPL-3.0-or-later Version Rust: 1.99
- AccuracyCoin nestest Mapper families Try in browser
+ AccuracyCoin nestest Mapper families Try in browser
Platform

diff --git a/SECURITY.md b/SECURITY.md index 970347976..66528b90b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,8 @@ Report against the latest release or `main`. | Version | Supported | Notes | | ------------- | --------- | ----- | | main | Yes | Where fixes land first | -| 3.0.x | Yes | The current line | +| 3.1.x | Yes | The current line | +| 3.0.x | Partial | Fixes are shipped forward into the current line, not backported | | 2.9.x | Partial | Fixes are shipped forward into the current line, not backported | | 2.8.x | Partial | Fixes are shipped forward into the current line, not backported | | 2.7.x | Partial | Fixes are shipped forward into the current line, not backported | diff --git a/crates/rustynes-test-harness/tests/release_notes_render_audit.rs b/crates/rustynes-test-harness/tests/release_notes_render_audit.rs index fb5cc993b..4ba13d164 100644 --- a/crates/rustynes-test-harness/tests/release_notes_render_audit.rs +++ b/crates/rustynes-test-harness/tests/release_notes_render_audit.rs @@ -205,3 +205,71 @@ fn the_paragraph_scanner_recognises_the_shapes_release_notes_use() { [] as [(usize, std::vec::Vec); 0] ); } + +/// The drafting placeholders this project writes while a release is being +/// assembled (`LADDER-FILL`, `BITSTREAM-FILL`: an upper-case word ending in +/// `-FILL`). Returns each one with its 1-based line number. +fn fill_placeholders(text: &str) -> Vec<(usize, String)> { + let mut out = Vec::new(); + for (i, line) in text.lines().enumerate() { + for token in line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) { + if let Some(stem) = token.strip_suffix("-FILL") + && !stem.is_empty() + && stem + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') + { + out.push((i + 1, token.to_string())); + } + } + } + out +} + +/// No drafting placeholder reaches a release body or the CHANGELOG. +/// +/// v3.1.0's release PR (#594) shipped `- The MiSTer core: LADDER-FILL.` in +/// its CHANGELOG section; a reviewer found it, no gate did. The release body +/// is built from `.github/release-notes/vX.Y.Z.md`, or from the CHANGELOG +/// section when there is none, so both are checked, every version. +#[test] +fn no_fill_placeholder_reaches_a_release_body() { + let root = repo_root(); + let mut files = vec![root.join("CHANGELOG.md")]; + let dir = root.join(".github/release-notes"); + for e in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read {}: {e}", dir.display())) { + let p = e + .unwrap_or_else(|e| panic!("read an entry of {}: {e}", dir.display())) + .path(); + if p.extension().is_some_and(|x| x == "md") { + files.push(p); + } + } + assert!(files.len() > 20, "only {} files found", files.len()); + let mut findings = Vec::new(); + for f in &files { + let text = + std::fs::read_to_string(f).unwrap_or_else(|e| panic!("read {}: {e}", f.display())); + for (line, token) in fill_placeholders(&text) { + findings.push(format!(" {}:{line} {token}", f.display())); + } + } + assert!( + findings.is_empty(), + "drafting placeholders left in release text:\n{}", + findings.join("\n") + ); +} + +#[test] +fn the_placeholder_scanner_finds_the_shapes_this_project_writes() { + let text = "- The MiSTer core: LADDER-FILL.\nbitstreams: BITSTREAM-FILL, then\n\ + a pre-fill note, a Fill-in, X-FILLER and -FILL are not placeholders"; + assert_eq!( + fill_placeholders(text), + vec![ + (1, "LADDER-FILL".to_string()), + (2, "BITSTREAM-FILL".to_string()) + ] + ); +} diff --git a/docs/STATUS.md b/docs/STATUS.md index 7678a5a55..09cbcb79d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1883,7 +1883,7 @@ without panicking (no `$6000` status protocol). | `vrc24test` | — | — | — | — | **Skipped (Track B1)**: link rot. AWJ's original forum attachment (id=10017 on forums.nesdev.org/viewtopic.php?p=203716) is auth-walled; the deletion is documented at archive.nes.science. No GitHub mirror found. | | `AccuracyCoin` | 1 | 1 | — | — | 100thCoin / Chris Siebert single-NROM accuracy battery (MIT license, 151 catalog rows across 22 suites: 146 scored tests + 5 visual-only `Power On State` tests sharing `$03FF`; the v2.0.1 upstream re-sync grew the catalog from 144 to 146 rows / 139 to 141 assigned tests, adding the PPU "ALE + Read" and "Hybrid Addresses" tests, and the **2026-09 re-sync** to upstream `69c8860` grew it again to 149 rows / 144 assigned across 22 suites — two new pages, `Advanced Background Evaluation` and `Advanced Sprite Evaluation`, which re-home eleven existing PPU tests that had outgrown `PPU Misc.`, plus three genuinely new tests, and the **v3.1.0 re-sync** to upstream `f5f41dc2` grew it to 151 rows / 146 scored with two new `CPU Behavior 2` tests, `DMA Landing on Write` and `DMC Reload Timing`). Interactive (D-Pad menu); the harness presses `START` to "run all tests on the ROM" then takes two parallel measurements. **(1) Framebuffer decoder** reads the 10×16 on-screen result grid by exact-pixel colour (5-colour palette: `#64A0FF` = pass, `#4F1000` = fail, `#DC834C` = partial-pass, `#4C4C4C` = no-test / not-run, `#FFFFFF` = border); this is the legacy path and has a known grid-stride bug that under-samples by ~31 cells. **(2) RAM-direct decoder** reads each test's result byte from its fixed CPU-RAM address (catalogued from upstream `AccuracyCoin.asm` in `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` and `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` — 151 `(suite, name, addr)` triples, regenerated by `scripts/accuracycoin-build/extract_catalog.py`) and decodes per-test pass/fail/error-code names + per-suite breakdowns. This is the authoritative path. **Current measured pass rate (RAM-direct): 100.00% (146/146, upstream `f5f41dc2`, v3.1.0)** on the default build. The re-sync's ROM found two defects, fixed in the same change: a DMC load DMA refused by a write took three cycles instead of four (`DMA Landing on Write`), and sprite evaluation took its start from OAMADDR at dot 0 instead of dot 65 and dropped a misaligned OAMADDR after an in-range X (`Misaligned OAM behavior`, which the pre-`f5f41dc2` ROM had recorded as a pass because its fail path returned into the test body without popping the return address). Before that, the two upstream PPU tests "ALE + Read" and "Hybrid Addresses" (briefly the only gaps at 139/141 after the v2.0.1 catalog re-sync) were **closed in v2.0.3** by promoting the 2-cycle-ALE PPU fetch model to the unconditional default. The `90.65%`, `84.17%` and the trajectory figures below are historical engine-lineage milestones (the pre-promotion v1.0.0-rc2 / Session-26 era), retained as history. Historical trajectory: `64.03%` (post-D2 baseline) → `67.63%` (post-D3, 7 6502 bus-pattern fixes) → `69.06%` (post-Phase-3 OAM DMA parity fix, +1 strict test flipped) → `69.78%` (post-FSM-fix recovery, +1 sprite-related sub-test flipped as a side-benefit of the `crates/rustynes-ppu/src/ppu.rs` dot-64 reset removal) → `76.98%` (post-Cascade-B DMC DMA scheduler, commit `9b0c81c` — closes all 8 tests in the `APU Registers and DMA tests` suite + 3 net elsewhere as side-benefits; +11 tests flipped) → `78.42%` (post-Cascade-A OAMADDR-during-rendering reset, commit `f29f7ca` — hardware-accurate per nesdev: OAMADDR is reset to 0 during dots 257-320 of every rendered scanline; +2 tests flipped — Sprite overflow behavior PASSES, Sprite 0 Hit advances from error 1 → error 13) → `79.14%` (post-session-7 OAMADDR-walks-during-eval + $4-aligned `$2004` write, commit `c230489` — closes `Address $2004 behavior` with code 16; +1 net flip) → `79.86%` (post-session-7 RMW ABS,X/Y unfixed-address dummy read, commit `32d5b18` — 18 RMW opcodes get the canonical cycle-4 unfixed-address dummy; flips `APU Tests :: Controller Clocking` and advances `Implied Dummy Reads` 2→3 + `Frame Counter IRQ` 6→7 via the SLO $4015,X bracket; +1 net flip) → `82.73%` (post-session-8 BG-pipeline cycle-9 reload + post-emit shift, commit `086ce4d` — fixes the long-standing 1-column BG pixel off-by-one identified in `docs/audit/cascade-a-investigation-2026-05-19.md`; flips `Sprite 0 Hit behavior` + `Sprite overflow behavior` + `Suddenly Resize Sprite` + `$2007 read w/ rendering`; +4 net flips, +2.87pp) → `83.45%` (post-session-24 Controller Strobing M2-low-defer write, Session-24 Phase 3 — deferred `$4016` commit buffer on `LockstepBus` mirrors Mesen2's `NesControlManager::ProcessWrites`; flips `APU Tests :: Controller Strobing` from `[error 4]` to PASS; +1 net flip) → **`84.17%` (post-session-26 Sprint 2 iter 5 Frame-Counter-IRQ split, 2026-05-23 — separates `FrameCounter::irq_flag` ($4015 bit 6 visibility) from `FrameCounter::irq_line_active` (CPU IRQ source driver) so Tests I/J/K/L/M/N/O all PASS without spuriously asserting the CPU IRQ line on inhibited frame-counter cycles; flips `APU Tests :: Frame Counter IRQ` from `[error 19]` to PASS; +1 net flip)**. Session-26 Sprint 2 iter 4 (APU Register Activation OAM-DMA chip-select gate) advanced the same suite's APU Register Activation entry internally from `[error 4]` to `[error 6]` but did not flip the catalog-headline metric. The previous `75.93%` headline reflected the framebuffer decoder's stride bug, not real accuracy. Strict floor in CI is **60%** — see `crates/rustynes-test-harness/tests/accuracycoin.rs::MIN_PASS_RATE`. the v0.9.x 80% target and the v1.0.0 90% gate were both cleared, and the default build measured **100.00% (144/144)** until the v3.1.0 re-sync (146/146 since) — the master-clock core is the default, the former C1 + sub-cycle residuals are closed, and the two v2.0.1 PPU tests were closed in v2.0.3 (see "Accuracy residuals" below). **No open AccuracyCoin gap, as of v2.6.18.** The 2026-09 re-sync opened two, both in the new `Advanced Sprite Evaluation` page. `Misaligned OAM2 Address` ($0495) closed in v2.6.17 by modelling `OAM2Address` as a live counter through sprite fetch. `Frozen OAM2 Increment` ($0493) closed in **v2.6.18**, and the long diagnosis this row used to carry is **RETRACTED in all three of its claims** — it said a `$2001` enable on dot 256 took effect a dot early (all four writes the ROM names are in force from the START of the dot it names), it blamed test 2 or 3 (the failure was test **4**, the false-positive guard, and the ROM omits an `INC ` re-runs them with their original `ready_for_review` payload. That is how #548's ready run was recovered: every job passed on attempt 2. - **A pull request tests Linux only unless its branch is `release/*` (2026-10-03, v2.9.8).** `select matrix` in `ci.yml` gives an ordinary PR `ubuntu-26.04` and `ubuntu-26.04-arm`; macOS and Windows test legs run only on `main`, `release/*` PRs, dispatch and the weekly cron. v2.9.8's release PR was `feat/v2.9.8-cadence`: 36 checks green, merged, then `main` failed on `test (windows-latest)` alone, so Auto Release skipped and the release had no tag. The cause was the checkout, not the code: CRLF on the Windows runner stopped the source-shape tests' split on `"\n#[cfg(test)]\nmod tests {"`, so `every_load_path_installs_a_dual_system_cabinet` counted its own strings (3 against 1). Fixed by `*.rs text eol=lf` in `.gitattributes` (#582, itself on a `release/*` branch so its PR ran Windows). **Name every release PR's branch `release/*`.** -- **A release whose goldens or commercial snapshots moved must raise `rustynes_core::EMULATION_EPOCH` (v3.0.0, ADR 0045).** The epoch is what tells a movie or a netplay peer which emulator *behaviour* it expects. `.rnm` format 5 records it, protocol 6 sends it, and a mismatch is refused, naming both epochs. Forget the bump and a movie recorded on the old behaviour replays its inputs on the new timing, and a mixed-version session desyncs, both silently: the exact failure the epoch was added to prevent. **The trigger is mechanical:** any re-blessed golden, moved `.snap`, or changed AccuracyCoin/blargg/commercial result between the last release and this one. It does not move for byte-identical refactors or performance work, frontend features, or new mapper families (no earlier output exists to differ from). Check it at the release cut, beside the CHANGELOG. **Since v3.1.0 a test enforces it** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment): a moved panel fingerprint fails unless the epoch is above the table's `last_release_epoch`. **At every release cut, set `# last_release_epoch` in `crates/rustynes-test-harness/golden/epoch_fingerprint.tsv` to the epoch being shipped**, in the release commit; skip it and the next release can move output under an unchanged epoch without the gate noticing. +- **A release whose goldens or commercial snapshots moved must raise `rustynes_core::EMULATION_EPOCH` (v3.0.0, ADR 0045).** The epoch is what tells a movie or a netplay peer which emulator *behaviour* it expects. `.rnm` records it (format 5 at v3.0.0, format 6 since v3.1.0), netplay sends it (protocol 6, `"RNE6"`, at v3.0.0; protocol 7, `"RNE7"`, since v3.1.0), and a mismatch is refused, naming both epochs. Forget the bump and a movie recorded on the old behaviour replays its inputs on the new timing, and a mixed-version session desyncs, both silently: the exact failure the epoch was added to prevent. **The trigger is mechanical:** any re-blessed golden, moved `.snap`, or changed AccuracyCoin/blargg/commercial result between the last release and this one. It does not move for byte-identical refactors or performance work, frontend features, or new mapper families (no earlier output exists to differ from). Check it at the release cut, beside the CHANGELOG. **Since v3.1.0 a test enforces it** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment): a moved panel fingerprint fails unless the epoch is above the table's `last_release_epoch`; a probe newly added to the panel is not a moved output and blesses at any epoch (since the PR #594 review). **At every release cut, set `# last_release_epoch` in `crates/rustynes-test-harness/golden/epoch_fingerprint.tsv` to the epoch being shipped**, in the release commit; skip it and the next release can move output under an unchanged epoch without the gate noticing. - **A runner outage made `CI success` green with nothing tested (2026-10-05, the #585 merge, run 37373901726).** No GitHub-hosted runner ever acquired `detect code changes` ("The job was not acquired by Runner of type hosted even after multiple attempts": no runner name, zero steps, cancelled after 15 minutes), so every gate downstream was skipped. The API reported the job `cancelled`, yet the aggregate's `contains(needs.*.result, 'cancelled')` step was itself skipped, so `needs` did not carry that value. `CI success`, `main`'s only required check, then reported success. **Fixed in v3.0.0** with a step that requires `changes` to have succeeded, and `setup` to have succeeded whenever `changes` found code. A docs-only change still passes, because `setup` skips exactly when `code` is false. The CodeQL "Push on main" run lost `Analyze (rust)` to the same outage. **A default-setup CodeQL run cannot be re-run**: the run and job retry endpoints answer 403, and re-applying the default-setup configuration returns the last run's id rather than starting one. The next push to `main` (or the weekly schedule) is what re-analyses. - **HOLD THE AUTO RELEASE BY CANCELLING MAIN'S CI RUN (v3.0.0).** `release-auto.yml` fires on `workflow_run: CI completed` on `main`, requires `conclusion == 'success'`, and tags that run's `head_sha`. When the maintainer asked for a full docs sweep after #586 had merged, `gh run cancel` on the main CI run of the merge commit left it `cancelled`, so no tag or release was made. The sweep then goes in as its own PR, and that PR's green main run is what tags v3.0.0, on the swept commit. Confirm with `gh release view vX` and `git ls-remote --tags origin vX`. The cancelled run's own `CI success` shows red, which is the outage gate working, not a failure. - **THE FIRST MAJOR BUMP FOUND TWO `bump_release.py` BLIND SPOTS (v3.0.0).** (1) Internal path dependencies carried `version = "2.0.0"`, a caret range no 3.x satisfies, so after the bump `cargo update -w` failed to resolve the workspace. No minor or patch bump can see it. `bump_internal_requirements` now moves them on every MAJOR, and run `cargo update -w` in BOTH workspaces (`crates/rustynes-cosim` has its own lock) after any bump. (2) A CHAIN anchor with its own description (`vX "C" released — . Built on ...`, root `ROADMAP.md`) kept the OLD release's description under the new name; CodeRabbit caught "v3.0.0 ... the tenth release of the v2.9.x line". The selftest had that exact shape and asserted only where the insertion landed. A described head now takes `--lead`, and the old text moves into the chain beside its own release. diff --git a/docs/compatibility.md b/docs/compatibility.md index 67c9794f6..73137556a 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -156,7 +156,7 @@ Bros.) boot and render with their correct 2C04 palette. **PlayChoice-10's second-screen instruction menu and its Z80 coprocessor are out of scope** — only the NES-game half runs (with the 2C03 palette). All of the above is gated on `ConsoleType::VsSystem`/`Playchoice10`; a stock `Nes` cart is byte-for- -byte unchanged (AccuracyCoin 100.00% -- 141/141 at the time, 144/144 from v2.6.18 -- plus both ROM oracles byte-identical). +byte unchanged (AccuracyCoin 100.00% -- 141/141 at the time, 144/144 from v2.6.18, both overstated by the older ROM's masked `Misaligned OAM behavior` failure, and 146/146 since v3.1.0 -- plus both ROM oracles byte-identical). Region timing (PAL/Dendy) is validated by automated gates (`ppu_region_constants_match_hardware` in `rustynes-ppu`; `region_timing.rs` in `rustynes-test-harness`). The R1 diff --git a/docs/libretro/UPSTREAM_SYNC.md b/docs/libretro/UPSTREAM_SYNC.md index 27c492326..81119718d 100644 --- a/docs/libretro/UPSTREAM_SYNC.md +++ b/docs/libretro/UPSTREAM_SYNC.md @@ -160,13 +160,13 @@ At this point, you can safely navigate to your repository settings on GitHub and Both forks were synced to upstream `master` (`libretro-super` `a7054054af`, `docs` `36e9222824`) and carry a branch `rustynes-v3.0.0-sync`. -**Status (checked 2026-10-07, v3.1.0 records item DOC-06).** Both were -submitted after the v3.0.0 tag. The `.info` PR, +**Status (checked 2026-10-08, v3.1.0 records item DOC-06).** Both were +submitted after the v3.0.0 tag, and both are **merged**. The `.info` PR, [libretro/libretro-super#2131](https://github.com/libretro/libretro-super/pull/2131), -**merged** on 2026-10-06 (`9c08e5e6f3`). The docs page PR, -[libretro/docs#1215](https://github.com/libretro/docs/pull/1215), is **open**, -with its one review finding fixed and answered. Until v3.1.0 this section said -no pull request was open. +merged on 2026-10-06 (`9c08e5e6f3`). The docs page PR, +[libretro/docs#1215](https://github.com/libretro/docs/pull/1215), merged on +2026-10-08 (`4a0c09f236`), after its one review finding was fixed and answered. +Until v3.1.0 this section said no pull request was open. | repo | branch commit | change | | --- | --- | --- | diff --git a/docs/mappers.md b/docs/mappers.md index 15eea5301..a93c5a690 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -973,7 +973,7 @@ chunked `NSFE` containers; the FDS-style `$5FF6/$5FF7` RAM banking remains defer ## Edge cases and gotchas 1. **MMC1 consecutive-write bug.** Writes on adjacent CPU cycles after the first are ignored — but only the **data** (bit 0): the bit-7 reset is never ignored (`nesdev_wiki/MMC1.xhtml`, "Consecutive-cycle writes"). *Bill & Ted's Excellent Adventure* needs the data half (an `INC` on `$FF` writes a reset, then a `$00` that must be dropped); *Shinsenden* needs the reset half (it sets bit 7 on an `RRA abs,X`'s second write and crashes if that reset is dropped). Until v2.8.2 the oracle filtered the reset too; the MiSTer RTL did not, and was right (RTL audit R-3.5a). Pinned by `a_reset_on_the_cycle_after_a_write_is_never_ignored` and `a_data_write_on_the_cycle_after_a_reset_is_ignored`. -2. **MMC3 IRQ pattern-table revision differences.** MMC3A (Sharp) generates IRQ even with latch = $00; MMC3B (NEC) does not. *Star Trek: 25th Anniversary* requires MMC3A behavior. Default to MMC3A unless NES 2.0 submapper specifies MMC3B (subm. 1) or MMC3C (subm. 2). +2. **MMC3 IRQ revision differences.** The Sharp MMC3B / MMC3C (the default) asserts whenever a clock leaves the counter at 0, so a latch of `$00` fires every scanline; the MMC3A and non-Sharp MMC3B (the alternate revision, `Mmc3Revision::Nec`) assert on a 1 -> 0 decrement and on a `$C001` reload to 0, but not when a counter already at 0 reloads 0 by itself. *Star Trek: 25th Anniversary* requires the Sharp behaviour. NES 2.0 submapper 4 selects the alternate revision (submapper 1 is the MMC6); an iNES 1.0 dump uses the default unless `Nes::set_mmc3_revision_override` forces one (v3.1.0). Until the v3.1.0 review this item called Sharp "MMC3A" and put MMC3B on submapper 1, both wrong. **The counter rule and the IRQ's deferred output (v2.9.9, T-ORACLE-001).** On each filtered A12 rise the counter follows the NESdev MMC3 page exactly: if the reload flag (set by a `$C001` write) is set or the counter is zero, it reloads from `$C000`'s latch and the flag clears; otherwise it decrements. The IRQ then asserts if the counter is zero and IRQs are enabled. On the default chip that is after any of the three paths; on the alternate chip it is only after a decrement. The IRQ line is raised **at the first per-cycle hook after the rise that asserts it**: `notify_a12` sets `irq_assert_pending_next_cycle`, and the next `notify_cpu_cycle` raises the line. Which CPU cycle that is comes from the bus's order: `Cpu::start_cycle` catches the PPU up to the access and then calls `SystemBus::cpu_clock`, which calls the hook, so a rise caught up before the access is raised in its own cycle and one caught up after it (`end_cycle`) from the next. That is the same split the removed `mmc3-m2-phase-irq` feature made from phase data (this paragraph said "one CPU cycle after the rise" for every rise until the #583 review; ADR 0002's 2026-10-05 correction). An `$E000` write in between cancels the assertion still in flight, as it is the same line. Which rises clock the counter is unchanged; only when the line is seen moves. Until v2.9.9 a `$C001` reload asserted only if the write had cleared a non-zero counter (a latch the page does not have), and the IRQ was seen on the cycle of the rise. That latch made `4-scanline_timing` sub-test 2 pass by raising the IRQ a scanline late, which is what failed sub-test 3. With the page's rule and the deferred output, both `4-scanline_timing` ROMs (`mmc3_test`, `mmc3_test_2`) first fail at sub-test 9 instead of 3, and `mmc3_test/5-MMC3` passes; with the PPU's A12 stream corrected (T-MMC3-BG-A12, `docs/ppu-2c02.md` pitfall 4: the background's fetches at the page's dot 324, and a visible line's dot 0 driving the background CHR address except where the odd-frame skip replaced it) they pass all 13 sub-tests. The delay replaced v2.0.0's `mmc3-m2-phase-irq` feature, which deferred only rises seen in the M2-high half of a cycle; it passed the same ROMs because it makes, in effect, the same split. This form takes that split from the order of the catch-up and the per-cycle hook, with no phase data from the bus, so it is kept and the feature is removed. The MMC3 save-state section is version 4 (it carries the in-flight flag); version 3 is refused. ADR 0002 keeps the history of the earlier attempts. 3. **MMC2/MMC4 latch on tile fetch.** PPU calls a "tile fetched" notification with the tile address; mapper switches CHR bank if tile == `$FD` or `$FE`. Used for Punch-Out's character animations. @@ -1031,7 +1031,7 @@ deleted. - **MMC3 default revision** when iNES (no submapper) is detected. *Answered:* the default is Sharp (the "normal" IRQ behaviour); NES 2.0 submapper 4 selects the alternate one (MMC3A and the non-Sharp MMC3B), and since v3.1.0 any - mapper-4 game can be forced either way (`Mapper::set_mmc3_revision_override`, + mapper-4 game can be forced either way (`Nes::set_mmc3_revision_override`, desktop `[emulation] mmc3_irq_revision`, `T-MMC3-NEC-OVERRIDE`). This line used to call the Sharp default "MMC3A", which is the other revision. - **Mapper #5 (MMC5) audio scope.** *Answered:* the two extra pulses and the diff --git a/docs/mobile-v2.9.3-run-sheet.md b/docs/mobile-v2.9.3-run-sheet.md index 65184b8a9..74736a16e 100644 --- a/docs/mobile-v2.9.3-run-sheet.md +++ b/docs/mobile-v2.9.3-run-sheet.md @@ -174,8 +174,11 @@ multi-touch (A13). One row was dropped because it is now **wrong**: "the picker offers iNES / NES 2.0 only". FDS and NSF shipped in v2.9.7 (T1-T7). Its accuracy paragraph ("AccuracyCoin 139/141") is replaced by the rule it stated: the device runs the host's byte-identical core, so AccuracyCoin (146/146 at -v3.1.0) is measured on the host and never on a phone, and a device that -diverges from the host's frames has a shell bug, not a core one. +v3.1.0) is measured on the host and never on a phone. A device that diverges +from the host's frames on the same ROM, from the same initial state, with the +same input stream points at the mobile integration (the shell, the bridge or +the build), not the shared core; if any of the three differ, compare those +first. | # | Platform | Step | Expect | Result | | --- | --- | --- | --- | --- | diff --git a/docs/ppu-2c02.md b/docs/ppu-2c02.md index 94520ee20..1783bb225 100644 --- a/docs/ppu-2c02.md +++ b/docs/ppu-2c02.md @@ -322,7 +322,9 @@ Per `ref-docs/research-report.md` §Sprite evaluation: position, `OAMADDR` stepping by one each, whatever the alignment: a start at `m = 3` copies the last byte of slot `n` and the first three of slot `n + 1` (until v3.1.0 the FSM stopped after one byte there). - - The fourth byte copied is the X position, and it is range-tested like Y. + - The fourth byte copied is evaluated AS the X position, and range-tested + like Y. In an aligned walk it is the sprite's X; from `m = 3` it is slot + `n + 1`'s attribute byte, read in the X position. **X in range: `OAMADDR += 1` only**, so a misaligned walk stays misaligned. **X out of range: `OAMADDR += 1`, then AND with `$FC`**, realigning. Aligned, both give the next multiple of four, so only misaligned OAM sees diff --git a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md index d39646ee7..0a0bb7358 100644 --- a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md +++ b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md @@ -441,9 +441,10 @@ variant is what would put the first two in front of a user at all.)* All remaining hard-tier accuracy residuals share **one root cause** and converge on the v2.0.0 one-clock + every-cycle-bus-access refactor. They are **outside the -AccuracyCoin oracle** (zero production-ROM impact; AccuracyCoin is an exact -**141/141** on the shipping default core, up from 139/139 when this paragraph was -written — the denominator grew in the v2.0.1 re-sync and v2.0.3 closed the two new +AccuracyCoin oracle** (zero production-ROM impact; AccuracyCoin was an exact +**141/141** on the shipping default core at the time, up from 139/139 when this +paragraph was written, and is 146/146 since v3.1.0, the earlier figures each +including the masked `Misaligned OAM behavior` failure — the denominator grew in the v2.0.1 re-sync and v2.0.3 closed the two new tests). The maintainer's standing decision through v1.7.0 is "keep deferring" point-fixes (ADR 0002 stop-condition; 15+ documented rollbacks); v2.0.0 is the one release licensed to break save-state/determinism and @@ -572,7 +573,7 @@ take this on. See [v2.0.0 plan](plans/v2.0.0-master-clock-plan.md) and ### 6c. Other v2.0-axis items -- `[ ]` **CPU-multiplier overclock** — distinct from the F3 dot-resolution +- `[x]` **CPU-multiplier overclock** — distinct from the F3 dot-resolution scanline-insert overclock (which shipped off-by-default in v1.7.0 beta.1); needs the timebase rewrite. The v1.5.0 "Enhancements" group's **sprite-limit-disable + overclock** controls are **staged-but-inert** pending @@ -596,6 +597,12 @@ take this on. See [v2.0.0 plan](plans/v2.0.0-master-clock-plan.md) and movies and netplay carry them, with one `.rnm` format and protocol bump. The sprite limit is render-only, so the overflow flag and evaluation timing stay exact.)* + *(2026-10-08: **shipped in v3.1.0**, both. `Nes::set_cpu_overclock(1..=4)` + (`T-CPU-OVERCLOCK`) runs the CPU k times faster with the APU, the mapper + counters and the PPU timers at the stock rate, exact on every region; the + sprite-limit option (`T-SPRITE-LIMIT`) draws the dropped sprites behind the + eight the console shows. Both ride in `HardwareOptions` (`.rnm` format 6, + protocol 7) and apply to both consoles of a Vs. DualSystem cabinet.)* - `[x]` **Full Vs. DualSystem dual-core (C)** — *(shipped v2.0.0 "Timebase" beta.5, commit `9fe44a19`: `crates/rustynes-core/src/vs_dualsystem.rs`, `pub enum Emu` (Single / Dual); desktop presentation v2.1.2 (`render_dual` in diff --git a/to-dos/README.md b/to-dos/README.md index 58ebe8dc2..5e47cffe2 100644 --- a/to-dos/README.md +++ b/to-dos/README.md @@ -26,7 +26,8 @@ production polish) → **`v1.1.0` "Scriptable" → `v1.2.0` "Curator" → `v1.3. "Studio" → `v1.7.0` "Forge"** (+ `v1.7.1`) **→ `v1.8.0` … `v1.8.8` "Atlas"** (the Android platform train), and from there through **`v2.0.0` "Timebase"** (the master-clock rewrite, ADR 0002), the v2.x accuracy, platform and audit lines, -to the current release, **`v3.0.0` "Cornerstone"** (2026-10-06, the API major). +through **`v3.0.0` "Cornerstone"** (2026-10-06, the API major), to the current +release, **`v3.1.0` "Bellwether"** (2026-10-08). `to-dos/ROADMAP.md` and `docs/STATUS.md` carry the current state; this file's v1.8.x-era sections below are kept as history. Version markers in the phase bodies that read `v1.x`/`v2.x` are the inbound **engine's** prior lineage diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 0b9369205..31ef07832 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -59,8 +59,8 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. - **Current release:** **RustyNES v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** - **The line after v3.0.0 (drafted 2026-10-07):** [`plans/v3.1-to-v4.0-line-plan.md`](plans/v3.1-to-v4.0-line-plan.md) indexes v3.1.0 → v4.0.0, with one plan file per release and the maintainer's 29 decisions (D1-D29) tabled there. In order (D29 moved the hardware release to the end of v3.9.x the same day): - - v3.0.1 "Mortar" (in progress); - - v3.1.0: options, emphasis, the AccuracyCoin re-sync; + - v3.0.1 "Mortar" (released 2026-10-07); + - v3.1.0 "Bellwether" (2026-10-08): options, emphasis, the AccuracyCoin re-sync; - v3.2.0 to v3.8.0: oracle themes alongside MiSTer phases F1-F4, the MiSTer features verified in simulation; - v3.9.0: the MiSTer RTL feature freeze and RC pair, mobile signing set up, and v4.0 preparation; - the hardware release, the last v3.9.x: the board session on that pair and the mobile device run, then the store listings; numbered after the session, no later than v4.0.0, with no feature RTL; diff --git a/to-dos/plans/v3.1.0-plan.md b/to-dos/plans/v3.1.0-plan.md index 674bd78a2..381f8168f 100644 --- a/to-dos/plans/v3.1.0-plan.md +++ b/to-dos/plans/v3.1.0-plan.md @@ -89,7 +89,7 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | # | Outcome | Evidence | | --- | --- | --- | | 1 | **Done.** Upstream `f5f41dc2`, 151 rows / 146 scored, **146 of 146**. Two defects fixed red first: the DMC load DMA a write refuses (4 cycles, not 3), and misaligned sprite evaluation (seed at dot 65, a four-byte copy from `m = 3`, the X range test). `Misaligned OAM behavior` had been a false pass on the old ROM (its fail path did not pop its return address, upstream `adacbc23`). `EMULATION_EPOCH` 3, BUS section 3. Extractor and `derive_indices.py` read `tblf1`/`tblf2` and fail closed; the mirror ROM is rebuilt. **Sibling consequence (read in `rtl/ppu2c02.sv`, 2026-10-07):** the RTL already seeds evaluation from OAMADDR at the end of the clear and already copies four bytes by a byte counter, but it never applies the X out-of-range `&$FC` (the inverse of the oracle's old defect); the DMC write-refusal path is unread yet. The pin move will move the AccuracyCoin goldens; the RTL needs the X rule (and the DMC rule, if absent), each with a gate that catches its mutant, written from the ROM's comments and nesdev, not from the oracle's Mesen2-derived evaluation region | battery red at errors 9, 3, 6, 7 in turn, each fix green; `misaligned_oam_eval_starts_at_dot_65_copies_four_bytes_and_tests_x` catches 3 of 3 mutants; TriCNES per-cycle diff at the test-9 `STA $5000` | -| 2 | **Done.** `tests/epoch_fingerprint.rs` + `golden/epoch_fingerprint.tsv` (epoch 3, `last_release_epoch` 2): seven panel ROMs, every frame's framebuffer, audio, end RAM, CPU cycles. Fails on moved output while the epoch equals the last release's and refuses that bless; after a rise, a re-bless suffices. ADR 0045 amended; the release cut must set `last_release_epoch` (`docs/agents/ci-and-release.md`) | with the cut simulated: control passes; both of this release's fixes reverted as mutants fail and their bless is refused (table unchanged); epoch 4 without bless fails, with bless passes; all files restored byte for byte | +| 2 | **Done.** `tests/epoch_fingerprint.rs` + `golden/epoch_fingerprint.tsv` (epoch 3; `last_release_epoch` 2 through development, set to 3 at the cut, `fb8a1243`): seven panel ROMs, every frame's framebuffer, audio, end RAM, CPU cycles. Fails on moved output while the epoch equals the last release's and refuses that bless; after a rise, a re-bless suffices. ADR 0045 amended; the release cut must set `last_release_epoch` (`docs/agents/ci-and-release.md`) | with the cut simulated: control passes; both of this release's fixes reverted as mutants fail and their bless is refused (table unchanged); epoch 4 without bless fails, with bless passes; all files restored byte for byte | | 3 | **Done.** `Nes::set_cpu_overclock(1..=4)`: the CPU divider is divided; the APU, DMC, mapper cycle hooks and PPU timers run on "stock steps" from an exact cycle schedule (`overclock_phase`, `apu_cycle`, BUS section 3; integer division made PAL x3 run x3.2 and Dendy x4 x5 until the PR #594 review); `run_frame`'s budget scales. `x1` byte-identical (epoch gate). Desktop: Settings combo, recorded by movies and matched by netplay (not held at stock like the scanline overclock). Not done: libretro and mobile options (neither exposes the scanline overclock either) | `cpu_overclock.rs` 6/6; mutants: every cycle a stock step (2 tests fail), `apu_cycle` parity flip and dropped debt on restore (snapshot test fails, after moving it to an audible ROM: ny2011 is silent for 300 frames and made it blind) | | 4 | **Done.** Render-only extras: up to 56 sprites fetched after the eighth real fetch, drawn behind the hardware eight, PPU snapshot v13. `Mapper::chr_reads_are_pure` (5 impure boards found by scanning every `ppu_read` body; pinned for every constructible id against `save_state`). Desktop checkbox now live | `sprite_limit.rs` 5/5 (RAM, audio, cycles identical frame by frame; blargg sprite_overflow 1-5 pass with it on); stimulus built in the test (spritecans never exceeds 7 per line, NEStress's 62 are transparent); mutants: extras not drawn, extras dropped on restore, MMC2 / J.Y. ASIC claiming purity, all caught | | 5 | **Done.** `.rnm` format 6 (minimum 6), netplay protocol 7 (`"RNE7"`, same `Sync` layout; `"RNE6"` refused naming its epoch); ADR 0044 amended | `a_format_5_movie_is_refused_as_too_old`, `a_protocol_6_peer_is_refused_as_another_version_naming_its_epoch` | @@ -97,7 +97,7 @@ on the v3.0.1 tree, and a mutation that reverts the fix and is caught. | 7 | **Done (scoped).** The inter-pixel colour artifacts were already reproduced by the raw signal-decode pass (v2.1.9); added the missing differential phase distortion there, as an opt-in knob (`diff_phase`, deg/row, default 0) in `signal_decode.wgsl`. Desktop only; mobile/web write 0; the Bisqwit CPU filter does not model it | `crt_stack_shaders_parse_and_validate` (naga, the wgpu front end) passes on the changed shader; the param test pins the knob and its 0 default. NOT shown on screen by an automated test: no GPU-output test exists for this pass, so the visual check is the maintainer's | | 8 | **Done.** `Mapper::set_mmc3_revision_override`, `Nes` / `HardwareOptions` (`mmc3_revision`), re-applied after a power-cycle rebuild; desktop `[emulation] mmc3_irq_revision`. Running `mmc3_test_2/6` under it FOUND a defect: the alternate revision did not assert on a `$C001` reload to 0 ("IRQ should be set when reloading due to clear"), which MMC3.md documents; fixed, Sharp unchanged | `mmc3_test_2_6_passes_under_the_alternate_revision_override` (and 5 then fails, proving the override acts); `the_mmc3_override_survives_a_power_cycle_and_clears`; all 20 MMC3 tests pass. Two older tests pinned the opposite (NEC silent on the reload) and were corrected with the wiki's text: `nec_asserts_once_on_a_c001_reload_to_zero_and_not_after`, and `mmc3_submapper_4_is_nec_and_0_is_sharp` now expects the single IRQ | | 9 | **Done.** `VsDualSystem` owns a rewind ring of whole RVSD containers (framebuffers included), `restore_quiet` keeps it, `restore` and `power_cycle` empty it; `RunAhead::run_cabinet_ahead` / `finish_cabinet`; `produce_dual_frame` routes `rewind_held` and the run-ahead depth; the desktop sizes the ring from `[rewind]` (`rewind_budget`, one definition replacing three copies). ADR 0032's amendment gained an outcome note | `vs_dualsystem_rewind.rs` (6 tests, on a cart whose screens change every frame; mutants: a slim sub block in the capture, a loud restore in the step back, both caught); `cabinet_runahead_matches_a_plain_run_on_both_screens` at n = 1, 2 (mutants: no rollback, capture left on, both caught); `a_cabinet_rewinds_and_runs_ahead_through_the_produce_path` (mutants: rewind ignored, run-ahead ignored, both caught) | -| 10 | **Done.** DOC-01..DOC-09 below, each checked against the code; the v1.8.x Android checklist folded into `docs/mobile-v2.9.3-run-sheet.md` as rows G1-G17 (only the rows nothing there covered; one row dropped as now wrong) and deleted, its three live links repointed. Also: the CHANGELOG now says plainly that every earlier AccuracyCoin 100% included the masked `Misaligned OAM behavior` failure | the table below | +| 10 | **Done.** DOC-01..DOC-09 below, each checked against the code; the v1.8.x Android checklist folded into `docs/mobile-v2.9.3-run-sheet.md` as rows G1-G18 (only the rows nothing there covered; one row dropped as now wrong; G18, the `play` flavor, restored after the PR #594 review) and deleted, its three live links repointed. Also: the CHANGELOG now says plainly that every earlier AccuracyCoin 100% included the masked `Misaligned OAM behavior` failure | the table below | ## Records item 10: DOC-01..DOC-09 @@ -111,7 +111,7 @@ transcript until now, with the v3.1.0 outcome of each. | DOC-03 | `docs/mappers.md` open questions | MMC3 default answered (and its "Sharp (MMC3A)" corrected), MMC5 audio answered, pirate boards answered by policy | | DOC-04 | `docs/nesdev-hardware-emulation-checklist.md` | AccuracyCoin residuals and the MMC3 #3 residual closed; the stale shifter kept as ACC-04; `vrc24test` confirmed still absent from the corpus | | DOC-05 | `to-dos/ROADMAP.md` | CI-05 closed as obsolete (no Intel macOS target since v1.6.0, ADR 0009); the open-questions placeholder and the forward ticket table were already done on 2026-10-07 | -| DOC-06 | `docs/libretro/UPSTREAM_SYNC.md` | libretro-super#2131 merged 2026-10-06 (`9c08e5e6f3`); docs#1215 open | +| DOC-06 | `docs/libretro/UPSTREAM_SYNC.md` | libretro-super#2131 merged 2026-10-06 (`9c08e5e6f3`); docs#1215 merged 2026-10-08 (`4a0c09f236`) | | DOC-07 | `VERSION-PLAN.md` "Planned next" | Already correct: the v3.0.1 cut added its row and the v3.1 line; verified, not edited | | DOC-08 | stale code comments | `rustynes-android/src/gfx.rs` (Bisqwit is implemented), `wasm.rs` (winit build, audio and IndexedDB all exist), `ntsc.rs` (the composite ladder exists), `netplay_ui.rs` (`mesh_net` exists; the desktop does not use it yet, v3.4.0) | | DOC-09 | `docs/ios-v1.9.9-readiness.md` | A pointer added under the historical text: FDS, NSF and DualSystem shipped on the bridge in v2.9.7 | From b607687f311b4aaa80cd35ef6d8a8f568fa80fab Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 07:02:00 -0400 Subject: [PATCH 19/21] docs(core): drop the last "debt" wording from the overclock snapshot The overclock bookkeeping became a phase in 11c96b9b, but three comments still described a master-clock debt: the BUS decoder's range check ("A debt is below one stock CPU cycle (16 master clocks on PAL ...)"), the snapshot-tail byte count in a bus test, and docs/scheduler.md. The check itself was already right (`overclock_phase >= MAX_CPU_OVERCLOCK`); only the words described the removed model. Found by Antigravity's review of #594. Comment and doc text only. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/bus.rs | 2 +- crates/rustynes-core/src/bus_snapshot.rs | 8 +++++--- docs/scheduler.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index da2c079ba..7fc22ddfd 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -4976,7 +4976,7 @@ mod four_score_tests { // The two device tags sit before a fixed tail with both ports empty: // mirroring override (1) + controller-run tail (22) + internal bus // (1) + the version-3 fields (DMC write-refusal latch 1, overclock - // debt 1, `apu_cycle` 8) follow them, and port 1's tag is the second. + // phase 1, `apu_cycle` 8) follow them, and port 1's tag is the second. // // v3.1.0: the version-3 bytes were missing from this sum for one // commit. With only the 1-byte latch appended the window still read diff --git a/crates/rustynes-core/src/bus_snapshot.rs b/crates/rustynes-core/src/bus_snapshot.rs index 7b387b04c..39d599f18 100644 --- a/crates/rustynes-core/src/bus_snapshot.rs +++ b/crates/rustynes-core/src/bus_snapshot.rs @@ -471,9 +471,11 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError> let internal_data_bus = r.u8()?; let dmc_load_write_delayed = r.bool()?; let overclock_phase = r.u8()?; - // A debt is below one stock CPU cycle (16 master clocks on PAL, the - // longest); anything larger is a corrupt file, refused here rather than - // clamped later. + // The phase indexes the overclocked cycles of one stock cycle, so it is + // below the largest multiplier; anything larger is a corrupt file, + // refused here rather than clamped later. (A phase valid for `x4` but not + // for the multiplier the restoring host runs is clamped to its last cycle + // by `restore`.) if overclock_phase >= crate::MAX_CPU_OVERCLOCK { return Err(SnapshotError::SectionInvalid { tag: "BUS ".into(), diff --git a/docs/scheduler.md b/docs/scheduler.md index e6c180b40..3f027a5eb 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -161,7 +161,7 @@ same real time. At `k = 1` the branch is never taken: every cycle is a stock step and the APU gets the CPU counter, so the output is byte-identical (the epoch fingerprint -gate's whole panel runs at `k = 1`). The debt and `apu_cycle` are in the BUS +gate's whole panel runs at `k = 1`). The phase and `apu_cycle` are in the BUS save-state section (version 3), because run-ahead restores mid-run; the multiplier is configuration, carried by `HardwareOptions` in movies and the netplay `config_digest`. `run_frame`'s cycle budget scales by `k`. Not hardware From 17823b67ea256825361e563843af2292c0672398 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 07:27:07 -0400 Subject: [PATCH 20/21] docs(release): the v3.1.0 "Bellwether" release notes The release body, written from the branch log of both repositories. It states the breaking changes (EMULATION_EPOCH 3, BUS section 3, PPU_SNAPSHOT_VERSION 13, .rnm format 6, netplay protocol 7), that every AccuracyCoin 100% before v3.1.0 included the masked Misaligned OAM behavior failure, and the release-candidate bitstream pair: seed 1 from eight at 261008, on-die 4afffd23 (+0.255 / +0.100 ns) and off-die 7b48198c (+0.188 / +0.001 ns; SDRAM read +0.449 / +1.184 ns), each byte-identical across two clean compiles, neither run on hardware. The test count is set after the final run at the release head. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.1.0.md | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/release-notes/v3.1.0.md diff --git a/.github/release-notes/v3.1.0.md b/.github/release-notes/v3.1.0.md new file mode 100644 index 000000000..9ea2d3cb9 --- /dev/null +++ b/.github/release-notes/v3.1.0.md @@ -0,0 +1,62 @@ +# RustyNES v3.1.0 — "Bellwether" + +The first release of the v3.1 to v4.0 line. AccuracyCoin is re-synced to its newest upstream, and the new ROM showed that every earlier 100% score included a failure the old ROM hid; both defects it found are fixed. Two long-shown options now work and travel with movies and netplay: a CPU overclock and the sprite-limit switch. PAL and Dendy emphasis is right, the alternate MMC3 revision is selectable and correct, the NTSC decode gains differential phase, and the two-screen Vs. cabinet gets rewind and run-ahead. The MiSTer core (RTL-1) now matches the emulator when a `$2006` write meets the PPU's address pipeline. **Save states, movies and netplay from v3.0.1 are refused.** + +## Breaking changes + +- **Save states from v3.0.1 do not load**: BUS section 3 (the DMC write-refusal latch and the overclock's position) and `PPU_SNAPSHOT_VERSION` 13 (the sprite-limit option's pending sprites). +- **Movies from v3.0.1 are refused**: `.rnm` format 6, whose options record carries the CPU overclock, the sprite-limit switch and the MMC3 revision. `EMULATION_EPOCH` rises from 2 to 3, because the two AccuracyCoin fixes change bus cycles and sprite evaluation. +- **Netplay**: protocol 7 (`"RNE7"`). A v3.0.x peer is refused as another emulator version, naming its epoch. + +## AccuracyCoin, and an overstated score + +AccuracyCoin is re-synced to upstream `f5f41dc2`: 151 rows, 146 scored, **146 of 146 pass**. The old ROM had a bug: when `Misaligned OAM behavior` failed part-way, its fail path returned into the test instead of exiting, and the test recorded a pass. Upstream fixed it. On the fixed ROM RustyNES failed parts of that test, and so did every earlier release. **Every AccuracyCoin 100% reported before v3.1.0 (144/144, and 141/141 before it) included that masked failure.** v3.1.0 fixes it, and the DMC load-on-write defect the new `DMA Landing on Write` test found, so this 146/146 is the first without it. + +## Fixed + +- **Misaligned sprite evaluation**: evaluation starts from OAMADDR as of dot 65, copies four bytes from a start at a slot's last byte, and stays misaligned after an in-range X. +- **A DMC load DMA refused by a write takes four cycles, not three.** +- **PAL and Dendy colour emphasis**: PPUMASK bits 5 and 6 swap meaning on the 2C07 and the Dendy, so every PAL game that set emphasis was tinted the wrong way. +- **The alternate MMC3 revision fires on a `$C001` reload to 0**, the single IRQ the NESdev page documents; blargg's `6-MMC3_alt` now passes under it. +- **The pattern viewer and HD packs no longer change the game on MMC2 / MMC4 boards** (Punch-Out!!, Fire Emblem) or three others: their "side-effect-free" CHR read flipped the board's CHR latch. + +## Added + +- **CPU overclock**, 2 to 4 times, against the same picture and sound: the APU, mapper counters and PPU stay at the stock rate. Movies record it; netplay peers must match. +- **"Disable 8-sprite-per-scanline limit" works.** It draws the dropped sprites behind the eight the console shows and changes nothing the game can see. +- **MMC3 IRQ revision** setting (Auto / Sharp / Alternate), carried in movies and netplay. +- **Differential phase** in the raw NTSC decode, display only, off by default. +- **Rewind and run-ahead on the Vs. DualSystem cabinet**, on the whole cabinet, so the two consoles never fall out of step. +- **The emulation-epoch rule is a test** (`T-EPOCH-FINGERPRINT`): output that moves without an epoch rise fails CI. + +## The MiSTer core + +- **Release candidate, not hardware-verified.** No hardware has run either bitstream. +- **It matches the re-synced AccuracyCoin, all 146 entries.** The new ROM found the core's version of both defects: a misaligned sprite evaluation now masks the address after an out-of-range X byte, and a `$4010` write on the DMC timer's reload edge sets that reload's period. The old `Misaligned OAM behavior` sub-test ROM, whose fail path fell through to a pass, is rebuilt from the new source; on it the core without the fix fails. +- **A `$2006` write that meets the PPU's address pipeline matches the emulator** (RTL-1). A new test ROM lands the copy on every dot of a rendering line: the core diverged on 28,129 of 331,838 background fetches and now matches all of them. One of the three rules involved, a copy delay that depends on where in the line the write lands, is the emulator's and is not documented (NESdev says a constant "1 to 1.5 dots"); it is provisional until a board can settle it. +- **30 of 34 AccuracyCoin sub-test ROMs now gate the core**, the other four out for stated reasons, and the core's oracle pin moves to v3.1.0: every golden that changed is attributed, and the other 646 artifacts are byte-identical. +- **The co-simulation ladder is 212 passed, 0 failed, 1 expected failure on-die and 213 passed, 0 failed, 1 expected failure off-die**, each from one run of a frozen tree with nothing skipped: v3.0.1's 200 / 201 plus twelve new gates, and no existing gate moved. +- **New bitstreams, because the fixes are RTL.** Both builds are compiled at fitter seed 1, chosen from eight seeds swept on one build date (261008), every one of which closes on both builds. Each was compiled twice to the same bytes: + - on-die `RustyNES_MiSTer-v3.1.0.rbf`, md5 `4afffd23f6c6d2577c2ead24fa2e6f54` (timing margin +0.255 ns setup, +0.100 ns hold, the largest on-die hold of the eight seeds); + - off-die `RustyNES_MiSTer-v3.1.0-offdie.rbf`, md5 `7b48198cc3b6a822e71ce54e53d2adbc` (+0.188 / +0.001 ns; the SDRAM read +0.449 / +1.184 ns, assuming zero board delay). The off-die build shares the on-die build's seed, and at it closes by one picosecond of hold, its thinnest of the eight. +- The bitstreams carry the sweep's build date, which may be earlier than the release date. +- A self-hosted workflow now runs the whole ladder on the maintainer's runner, on manual dispatch. + +## Records + +The MiSTer contribution page was rewritten in September; the new page and what it means for the submission are recorded, and the checklist re-scoped. Nine stale documents were corrected against the code, and the v1.8.x Android checklist is folded into the mobile run sheet. + +## Verification + +- `--features test-roms`: 3,263 passed, 0 failed, 11 ignored. +- The local commercial suites: `external_real_games` 60/0, `external_extended` 137/0, `external_coverage` 6/0 over 744 staged ROMs. The one moved baseline is *Millionaire* (Sachen), a PAL game whose emphasis colours the PAL fix corrected. +- AccuracyCoin 146/146 at upstream `f5f41dc2`, nestest 0-diff. +- The epoch fingerprint gate passes at epoch 3, now recorded as the released epoch. + +## Install + +- Download the pre-built binaries for Linux, macOS, and Windows below. +- The MiSTer core bitstreams are attached below, as a release candidate, not hardware-verified. The on-die build is `RustyNES_MiSTer-v3.1.0.rbf`, also attached under its datecoded name `RustyNES_20261008.rbf`, and the off-die build is `RustyNES_MiSTer-v3.1.0-offdie.rbf`. +- The WebAssembly build is live at [doublegate.github.io/RustyNES](https://doublegate.github.io/RustyNES/). +- The RetroArch core is in RetroArch's Online Updater on the platforms the libretro buildbot publishes to. +- Licensed under GPL-3.0-or-later. From 727475416f0c6a0787982cd63a48fc3aa054fc5e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 07:51:42 -0400 Subject: [PATCH 21/21] docs: v3.1.0 test count at the release head, 3,269 / 0 / 11 `cargo test --workspace --release --no-fail-fast --features test-roms` at 17823b67: 3,269 passed, 0 failed, 11 ignored. The 3,263 these files gave was measured at 5f7eac66. Since then: four tests from the review rounds (the cabinet options, the MMC3 revision across a restore, the epoch classifier, switching the overclock back to x1) made 3,267 at c749c438, and the two placeholder-gate tests make 3,269. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.1.0.md | 2 +- CHANGELOG.md | 2 +- docs/STATUS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/release-notes/v3.1.0.md b/.github/release-notes/v3.1.0.md index 9ea2d3cb9..ec39ad7ae 100644 --- a/.github/release-notes/v3.1.0.md +++ b/.github/release-notes/v3.1.0.md @@ -48,7 +48,7 @@ The MiSTer contribution page was rewritten in September; the new page and what i ## Verification -- `--features test-roms`: 3,263 passed, 0 failed, 11 ignored. +- `--features test-roms`: 3,269 passed, 0 failed, 11 ignored. - The local commercial suites: `external_real_games` 60/0, `external_extended` 137/0, `external_coverage` 6/0 over 744 staged ROMs. The one moved baseline is *Millionaire* (Sachen), a PAL game whose emphasis colours the PAL fix corrected. - AccuracyCoin 146/146 at upstream `f5f41dc2`, nestest 0-diff. - The epoch fingerprint gate passes at epoch 3, now recorded as the released epoch. diff --git a/CHANGELOG.md b/CHANGELOG.md index 90b652870..96e49f10a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,7 +169,7 @@ run-ahead. **Save states, movies and netplay from v3.0.1 are refused.** ### Verification -- `cargo test --workspace --features test-roms --release`: **3,263 passed, 0 +- `cargo test --workspace --features test-roms --release`: **3,269 passed, 0 failed, 11 ignored**. The epoch fingerprint gate passes at epoch 3, now recorded as the released epoch. - AccuracyCoin **146/146** at upstream `f5f41dc2`; `nestest` 0-diff. diff --git a/docs/STATUS.md b/docs/STATUS.md index 09cbcb79d..d55a9de07 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1883,7 +1883,7 @@ without panicking (no `$6000` status protocol). | `vrc24test` | — | — | — | — | **Skipped (Track B1)**: link rot. AWJ's original forum attachment (id=10017 on forums.nesdev.org/viewtopic.php?p=203716) is auth-walled; the deletion is documented at archive.nes.science. No GitHub mirror found. | | `AccuracyCoin` | 1 | 1 | — | — | 100thCoin / Chris Siebert single-NROM accuracy battery (MIT license, 151 catalog rows across 22 suites: 146 scored tests + 5 visual-only `Power On State` tests sharing `$03FF`; the v2.0.1 upstream re-sync grew the catalog from 144 to 146 rows / 139 to 141 assigned tests, adding the PPU "ALE + Read" and "Hybrid Addresses" tests, and the **2026-09 re-sync** to upstream `69c8860` grew it again to 149 rows / 144 assigned across 22 suites — two new pages, `Advanced Background Evaluation` and `Advanced Sprite Evaluation`, which re-home eleven existing PPU tests that had outgrown `PPU Misc.`, plus three genuinely new tests, and the **v3.1.0 re-sync** to upstream `f5f41dc2` grew it to 151 rows / 146 scored with two new `CPU Behavior 2` tests, `DMA Landing on Write` and `DMC Reload Timing`). Interactive (D-Pad menu); the harness presses `START` to "run all tests on the ROM" then takes two parallel measurements. **(1) Framebuffer decoder** reads the 10×16 on-screen result grid by exact-pixel colour (5-colour palette: `#64A0FF` = pass, `#4F1000` = fail, `#DC834C` = partial-pass, `#4C4C4C` = no-test / not-run, `#FFFFFF` = border); this is the legacy path and has a known grid-stride bug that under-samples by ~31 cells. **(2) RAM-direct decoder** reads each test's result byte from its fixed CPU-RAM address (catalogued from upstream `AccuracyCoin.asm` in `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` and `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` — 151 `(suite, name, addr)` triples, regenerated by `scripts/accuracycoin-build/extract_catalog.py`) and decodes per-test pass/fail/error-code names + per-suite breakdowns. This is the authoritative path. **Current measured pass rate (RAM-direct): 100.00% (146/146, upstream `f5f41dc2`, v3.1.0)** on the default build. The re-sync's ROM found two defects, fixed in the same change: a DMC load DMA refused by a write took three cycles instead of four (`DMA Landing on Write`), and sprite evaluation took its start from OAMADDR at dot 0 instead of dot 65 and dropped a misaligned OAMADDR after an in-range X (`Misaligned OAM behavior`, which the pre-`f5f41dc2` ROM had recorded as a pass because its fail path returned into the test body without popping the return address). Before that, the two upstream PPU tests "ALE + Read" and "Hybrid Addresses" (briefly the only gaps at 139/141 after the v2.0.1 catalog re-sync) were **closed in v2.0.3** by promoting the 2-cycle-ALE PPU fetch model to the unconditional default. The `90.65%`, `84.17%` and the trajectory figures below are historical engine-lineage milestones (the pre-promotion v1.0.0-rc2 / Session-26 era), retained as history. Historical trajectory: `64.03%` (post-D2 baseline) → `67.63%` (post-D3, 7 6502 bus-pattern fixes) → `69.06%` (post-Phase-3 OAM DMA parity fix, +1 strict test flipped) → `69.78%` (post-FSM-fix recovery, +1 sprite-related sub-test flipped as a side-benefit of the `crates/rustynes-ppu/src/ppu.rs` dot-64 reset removal) → `76.98%` (post-Cascade-B DMC DMA scheduler, commit `9b0c81c` — closes all 8 tests in the `APU Registers and DMA tests` suite + 3 net elsewhere as side-benefits; +11 tests flipped) → `78.42%` (post-Cascade-A OAMADDR-during-rendering reset, commit `f29f7ca` — hardware-accurate per nesdev: OAMADDR is reset to 0 during dots 257-320 of every rendered scanline; +2 tests flipped — Sprite overflow behavior PASSES, Sprite 0 Hit advances from error 1 → error 13) → `79.14%` (post-session-7 OAMADDR-walks-during-eval + $4-aligned `$2004` write, commit `c230489` — closes `Address $2004 behavior` with code 16; +1 net flip) → `79.86%` (post-session-7 RMW ABS,X/Y unfixed-address dummy read, commit `32d5b18` — 18 RMW opcodes get the canonical cycle-4 unfixed-address dummy; flips `APU Tests :: Controller Clocking` and advances `Implied Dummy Reads` 2→3 + `Frame Counter IRQ` 6→7 via the SLO $4015,X bracket; +1 net flip) → `82.73%` (post-session-8 BG-pipeline cycle-9 reload + post-emit shift, commit `086ce4d` — fixes the long-standing 1-column BG pixel off-by-one identified in `docs/audit/cascade-a-investigation-2026-05-19.md`; flips `Sprite 0 Hit behavior` + `Sprite overflow behavior` + `Suddenly Resize Sprite` + `$2007 read w/ rendering`; +4 net flips, +2.87pp) → `83.45%` (post-session-24 Controller Strobing M2-low-defer write, Session-24 Phase 3 — deferred `$4016` commit buffer on `LockstepBus` mirrors Mesen2's `NesControlManager::ProcessWrites`; flips `APU Tests :: Controller Strobing` from `[error 4]` to PASS; +1 net flip) → **`84.17%` (post-session-26 Sprint 2 iter 5 Frame-Counter-IRQ split, 2026-05-23 — separates `FrameCounter::irq_flag` ($4015 bit 6 visibility) from `FrameCounter::irq_line_active` (CPU IRQ source driver) so Tests I/J/K/L/M/N/O all PASS without spuriously asserting the CPU IRQ line on inhibited frame-counter cycles; flips `APU Tests :: Frame Counter IRQ` from `[error 19]` to PASS; +1 net flip)**. Session-26 Sprint 2 iter 4 (APU Register Activation OAM-DMA chip-select gate) advanced the same suite's APU Register Activation entry internally from `[error 4]` to `[error 6]` but did not flip the catalog-headline metric. The previous `75.93%` headline reflected the framebuffer decoder's stride bug, not real accuracy. Strict floor in CI is **60%** — see `crates/rustynes-test-harness/tests/accuracycoin.rs::MIN_PASS_RATE`. the v0.9.x 80% target and the v1.0.0 90% gate were both cleared, and the default build measured **100.00% (144/144)** until the v3.1.0 re-sync (146/146 since) — the master-clock core is the default, the former C1 + sub-cycle residuals are closed, and the two v2.0.1 PPU tests were closed in v2.0.3 (see "Accuracy residuals" below). **No open AccuracyCoin gap, as of v2.6.18.** The 2026-09 re-sync opened two, both in the new `Advanced Sprite Evaluation` page. `Misaligned OAM2 Address` ($0495) closed in v2.6.17 by modelling `OAM2Address` as a live counter through sprite fetch. `Frozen OAM2 Increment` ($0493) closed in **v2.6.18**, and the long diagnosis this row used to carry is **RETRACTED in all three of its claims** — it said a `$2001` enable on dot 256 took effect a dot early (all four writes the ROM names are in force from the START of the dot it names), it blamed test 2 or 3 (the failure was test **4**, the false-positive guard, and the ROM omits an `INC