Accuracy Ledger¶
The single at-a-glance record of where RustyNES is cycle-exact, where it uses
a documented approximation, and where a behavior is a by-design non-goal
or out of scope. This exists so an audit can tell those apart — "not modeled"
is very different from "no oracle exists to model against" or "deliberately out of
scope." docs/STATUS.md remains the authoritative per-suite pass-count record;
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 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.
nestest 0-diff, cpu_interrupts_v2 5/5, ppu_sprites 19/19,
mmc3 A12-IRQ 18/18, ppu_vbl_nmi 10/10, the 60-ROM commercial byte-identity
oracle 60/60. The items below are the residual approximations, each with its
disposition under the v2.1.0 "Fathom" accuracy-remediation line
(to-dos/plans/v2.1.0-fathom-accuracy-remediation-plan.md).
Legend¶
- Remediated — closed (or being closed) in the Fathom line.
- No stricter oracle — the reference test only defines a tolerance band / visual bar; there is nothing to tighten against. Not a defect.
- Deferred — genuine engineering, scheduled but not yet done.
- Out of scope — deliberately not modeled.
| Area | Nature | Oracle | Disposition |
|---|---|---|---|
| Palette backdrop-override ($3F00-$3FFF, render disabled) | Was not modeled; PPU outputs palette[v & 0x1F] not the backdrop |
full_palette / flowing_palette visual corpus |
Remediated (F1.1) |
OAMADDR forced to 0 across dots 257-320 + $2004 $E3 attribute-byte mask |
Already modeled; both correct | AccuracyCoin $2004 / Sprite0Hit (141/141) |
Verified — F1.2 added a fast unit regression guard |
OAMADDR & 0xF8 render-start OAM copy |
Not modeled on the default revision (revision-dependent, unreliable) | none | Out of scope on the default Rp2c02H — Mesen2, ares, and TriCNES all omit it. The related OAMADDR $2003 write corruption is an opt-in Rp2c02G model (row below) |
OAMADDR ($2003) write-during-render OAM corruption (P5) |
A $2003 write while rendering is active copies OAM row 0 over the row (value>>3)&0x1F (reuses the CorruptOAM row-copy), gated behind the opt-in PpuRevision::Rp2c02G; default Rp2c02H never arms it |
No test ROM in-suite; Huge Insect is the reference title but its per-title byte output is not independently oracle-verified here | Landed (v2.1.7 P5) — default-off / byte-identical. Honest opt-in approximation: the exact 2C02 letter-revision taxonomy of the glitch and its per-title bytes are not oracle-verified; offered as a single "earlier revision" model, not exact silicon fidelity. Config re-applied on load (not snapshotted); the corruption state it arms already round-trips via the v6 PPU snapshot tail |
| Power-up palette RAM (P5) | Palette RAM is uninitialized at power-on; optional PaletteInit::Blargg loads the canonical blargg power-up dump (6-bit masked) instead of the all-zero default |
No pass/fail ROM (blargg dump is the de-facto reference) | Landed (v2.1.7 P5) — default-off / byte-identical. Default Zeroed keeps the established all-zero power-up palette; writes only palette_ram (already snapshotted) so no snapshot-format change |
| Power-on work-RAM model (P5) | Work RAM is uninitialized at power-on; optional PowerOnRam::Seeded(u64) (deterministic xorshift64) / Filled(u8) for software that reads it (FF RNG, RCR, Cybernoid) |
No pass/fail ROM (hardware is itself non-deterministic; RustyNES stays deterministic per config) | Landed (v2.1.7 P5) — default-off / byte-identical. Default Zeroed = all-zero (what CI/oracle/save-state tests use); all fills deterministic (no wall-clock/OS RNG); stored so power_cycle == fresh boot |
| PPU open-bus decay | Refresh map matches the Blargg ppu_open_bus table exactly; ~600 ms is the spec decay value (per-group model) |
ppu_open_bus (verifies the map, not a stopwatch) |
Verified — F1.3 audit + regression test; per-bit timing has no oracle and isn't pursued |
| 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). 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) |
FDS DRAM-refresh-watchdog IRQ ($4030.D1) |
Not modeled. The FDS BIOS/hardware raises periodic IRQs tied to DRAM-refresh-row-vs-access cycle accounting while $4023.D0=0; per upstream (TakuikaNinja's FDS-4030D1-Addr research, NESdev Wiki) this is itself still under active hardware research and not modeled by most current FDS emulators either |
TakuikaNinja's FDS-4030D1-Addr probe (gitignored, tests/roms/external/fds-takuikaninja/, no permissive license — see tests/roms/external/README.md), consumed by the RUSTYNES_FDS_BIOS-gated fds_4030d1_addr_with_real_bios test |
Out of scope / honest residual, tracked not asserted — the gated test only proves construction + a bounded real-BIOS run complete without panicking; it does not assert a specific watchdog timing value, since neither RustyNES nor the public research has pinned one yet. Revisit if/when upstream hardware research settles the exact behavior |
| Zapper light-timing | Single-pixel per-frame framebuffer sample | Photodiode aperture (3x3 field-of-view, >=2 bright pixels — ZAPPER_APERTURE_*) vs the PPU per-dot output, plus (v2.2.3 A3) the beam-relative temporal model; zapper_light_detected_for_bright_region / zapper_aperture_rejects_lone_bright_pixel / zapper_temporal_light_follows_the_beam / zapper_frame_model_is_scanline_invariant_but_temporal_is_not unit tests |
Hardened (v2.2.0), temporal model added opt-in (v2.2.3 A3), PROMOTED TO DEFAULT (v2.3.6). The ~19-26-scanline photodiode hold is modelled: ZapperState::light_at_scanline makes light a function of where the CRT beam is at the moment of the read — dark before the beam paints the aim row, lit for ZAPPER_LIGHT_HOLD_SCANLINES, dark once drained — which the frame-granular model structurally cannot express (it returns one answer per frame, sampled at end-of-frame, so every read during frame N reports frame N-1). v2.3.6 CORRECTION: this row previously justified Default OFF with "the supported titles re-poll every frame and are satisfied by either model". That was false. Duck Hunt requires the gun to see nothing for one frame and then a bright spot in the next; under the frame model it received that probe inverted (bright on the blanked frame, dark on the target frame) and discarded every shot before hit-testing, so no duck could ever be hit — reported by the maintainer, reproduced headlessly from the game's own $4017 traffic. The claimed absence of an oracle was also wrong: the game itself is one. Also fixed in v2.3.6: the beam-relative sampler read aperture rows the beam had not finished painting, so it reported light on an all-black screen (aperture_is_bright_painted). Measured A/B, same ROM/aim/inputs — frame model: score 000000, duck flying; beam-relative: score 000500, duck hit. Regression test duck_hunt_zapper_shot_can_score asserts the game's own scoreboard, mutation-checked. Deterministic and stateless either way — a pure fn of framebuffer + aim + scanline, so nothing new to serialize and no save-state or rollback impact |
| BestEffort mapper tier (26 families, was 112) | Register-decode + save-state round-trip only; off the oracle gate | mapper_tier_honesty.rs invariant |
Mostly remediated (F3): 86 promoted to Curated with commercial-ROM oracle; the 26 left have no cleanly-booting dump (16 NES 2.0 high-id + 8 no-cart + 2 jam-at-boot) |
| MMC3 R1/R2 scanline-IRQ (ADR 0002) | Closed (T-MMC3-BG-A12, in v3.0.0): both 4-scanline_timing ROMs pass all 13 sub-tests |
mmc3_test_2/4 + mmc3_test/4 strict tests (un-ignored); background_a12_rises_at_the_mmc3_pages_dot_324 and scanline_0_dot_0_drives_bg_chr_only_when_the_skip_did_not_replace_it (PPU unit tests); mmc3_r1r2_phase_probe A12-phase probe (v2.1.5) |
The background fetches report A12 at the MMC3 page's dot 324 (they reported at their read dots, two dots later), and a visible line's dot 0 drives "the same CHR address that is later used to fetch the low background tile byte" (NESdev PPU rendering), except scanline 0's on an odd frame, which the skip replaces with a nametable tick: the MMC3 page's "decrement the counter twice every other vertical redraw". Moved v2.9.9 (T-ORACLE-001) before that: the NESdev reload rule replaced the $C001 non-zero-clear discriminator and the IRQ output was deferred to the next per-cycle hook (ADR 0002's 2026-10-05 correction): sub-test 3 → 9, mmc3_test/5-MMC3 passes, mmc3_test/6-MMC6 fails only at its alternate-revision assertion (by design). The v2.1.5 phase study and the v2.9.5 refutation of the M2-edge low-time filter stand as history in ADR 0002 |
| PPU register write placement — M2-low vs phi2 (v2.6.17) | A 6502 commits a write at phi2, the last of a CPU cycle's three PPU dots; this core applies PPU register writes at M2-low, the first, because Cpu::start_cycle catches the PPU up before the bus access. Measured half a dot early: the commit lands 6 master clocks into the CPU cycle (1.5 dots) where phi2 is 8 (2.0). v2.6.17's "two dots early" figure is RETRACTED — see the plan's SETTLED section; a 2-master-clock nudge is sufficient to flip Frozen OAM2 Increment, which a two-dot error could not be. Several PPU behaviours compensate for it, one of which — mask_for_skip_check, the two-stage delay pipeline consumed by the dot-339 odd-frame skip — documents itself as a compensation in its own comment |
AccuracyCoin Frozen OAM2 Increment (the ROM names the dots it writes on: 242 and 256, where this core applies 240 and 254); ppu_vbl_nmi/10-even_odd_timing as the independent adjudicator; terminus_control.rs as the first-difference control |
Known divergence, measured and deliberately NOT corrected (v2.6.17 "Terminus", maintainer decision). The diagnosis is confirmed and the change was refuted by its own pre-written gate: the write moved alone reads 141/144 and breaks 10-even_odd_timing (code 09), and the best combination found — phi2 + the dot-321 OAM2 increment + a one-stage mask_for_skip_check — reads 142/144 against the 143/144 that shipped at v2.6.17 (144/144 from v2.6.18, by a different mechanism entirely -- see the Frozen OAM2 row), plus a save-state epoch and six re-baselined framebuffer goldens. Replacing a documented compensation with an undocumented one is worse than keeping it. The knob survives as the default-off phi2-write-sweep feature so a future attempt re-measures rather than rebuilding the apparatus; the three conditions for reopening are listed in the plan's CLOSED section Re-measured v2.9.5 on the 144/144 tree (phi2sweep.rs): write +2 or +4 alone reads 142/144 (loses Arbitrary Sprite zero and Stale Sprite Shift Regs), read and write both moved reads 138/144 (loses the six NMI entries), and read +2 / write +2 reads 142/144. It gains nothing at any placement. No test adjudicates in favour of moving it, so it stays as shipped |
Frozen OAM2 Increment (AccuracyCoin, $0493) |
Secondary-OAM address behaviour while rendering is toggled mid-fetch, and the dot on which a $2001 change reaches each consumer |
AccuracyCoin Advanced Sprite Evaluation :: Frozen OAM2 Increment, and its sub-tests/ standalone ROM |
CLOSED in v2.6.18. The row's previous cause is RETRACTED and kept here because each wrong part cost a refuted fix: it blamed the write placement, and all four writes the ROM names take effect during dot N-1 and are in force from the START of dot N, exactly where the ROM says; it read the failure as test 2 or 3, and it was test 4, whose $0E code is indistinguishable from test 3's because the ROM omits an INC <ErrorCode between them; and it implicated the OAM2 freeze, which per-dot instrumentation showed byte-identical to the passing case. The real rule is that a mask change during dot N must not act on dot N -- applied at the dot-256 vertical increment (a two-dot gate, hoisted out of the shared block) and the dot-339 OAM2 reset (which stopped conjoining the live mask) |
Misaligned-OAM out-of-range OAMADDR advance (v2.6.17) |
AccuracyCoin's README states two rules: secondary OAM full is +5 (the buggy n+m increment, implemented for releases), not full is +4 then & $FC — which clears the byte index. The second was not implemented; the FSM carried the misaligned index forward |
No ROM in the corpus discriminates it. Instrumented, the not-full out-of-range branch fires 56,953,944 times per battery run, of which exactly 114 have a non-zero byte index — the only case the rule changes, and the battery reads 143/144 either way | REMEDIATED (v2.6.17) — adopted only once a stimulus existed that could fail without it: misaligned_oam_out_of_range_advance_follows_both_rules drives one y-test from OAMADDR = $05 past an out-of-range Y and asserts (n, m) for both rules. Recorded here because "the battery did not move" is a measured quantity rather than an absence of evidence, and because the test — not the README — is what authorises the change |
| APU non-linear mixer | Lookup-table matches within the apu_mixer band |
apu_mixer (analog-cancellation, tolerance) |
No stricter oracle — the LUT already passes; ±4% is honest |
| Expansion-audio levels — VRC6 / MMC5 / N163 (v2.1.6; VRC6 recalibrated v2.2.7) | Full-volume expansion square vs 2A03 pulse: VRC6 ≈1.0× (was ≈1.51×), MMC5 ≈1.0× ("equivalent"), N163 1-ch ≈6.0×. MMC5/N163 calibrated to Mesen2 mixer weights (the accuracy bar), cross-checked vs nestopia/puNES/fceux/tetanes; VRC6 now calibrated to the NESdev/field consensus instead | bbbradsmith db_vrc6a/b, db_mmc5, db_n163 via the audio_expansion.rs level_db_* oracle (measured peak ratio) |
Remediated (v2.1.6); VRC6 re-remediated (v2.2.7 "Timbre II") — the v2.1.6 VRC6 target (VRC6_MIX_SCALE=979, ≈1.51×) reproduced Mesen2's ×5 mixer weight, but that convention is a Mesen2-only outlier: the NESdev wiki states the VRC6 pulse is loudness-equivalent to a 2A03 pulse, and rustico/tetanes/BizHawk each encode that equivalence exactly (ares/higan/nestopia reach it via a sum/61 normalization). VRC6_MIX_SCALE moved 979 → 650 (≈1.0×); the db_vrc6a/b snapshots were re-blessed (audio-only, framebuffer/cycle-count byte-identical). MMC5 650/40, NAMCO163_MIX_SCALE=261 unchanged since v2.1.6 (were 256, 256/16, 64 — N163 was ~12 dB too quiet). Base-APU byte-identity preserved (expansion is a separate additive mix_audio term = 0 for non-expansion mappers) |
| Expansion-audio level — Sunsoft 5B absolute (v2.1.6 → v2.2.3) | Log-volume DAC shape exact (×1.4126/step, LUT[12]=668/LUT[15]=1882 vs Mesen2 [63,177]). The absolute level is now calibrated too: SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471, measured (db_5b read 0.0685×, ~23 dB too quiet) against the Mesen2-derived target LUT[12]=63 × weight 15 / 746.9 = 1.265× (full scale 177×15/746.9 = 3.554×) |
db_5b via the new level_db_5b oracle (1.265 ± 0.04, measured 1.2651); step law by sunsoft5b_volume_dac_follows_logarithmic_step_law |
REMEDIATED (v2.2.3, A1) — the blocker was never the value but the type: Mapper::mix_audio returned i16 and the corrected full-scale tone is 1882 × 18.471 = 34,761, past i16::MAX for ONE channel (three tones ≈104 k, 3.2× over). Widening the trait return to i32 unblocked it; shape and level are now separately pinned. Non-5B boards return the values they always did — the widening is representational only, and the other 24 audio-expansion tests plus AccuracyCoin 141/141 are unchanged. v2.2.7 "Timbre II" completed the DAC shape for envelope mode: the 5-bit hardware envelope level previously truncated to 4 bits (>> 1) before indexing the 16-entry SUNSOFT5B_LOG_VOL shape table (the wiki's documented 3 dB/step approximation), and now indexes a new 32-entry SUNSOFT5B_LOG_VOL32 table (×1.1885/step = 1.5 dB, matching nestopia/rustico) with the full 5-bit level — LOG_VOL32's odd entries equal LOG_VOL exactly (log_vol32_odd_entries_match_4bit). Fixed 4-bit volume (already correct 3 dB/step) and the absolute level (1.265×) are unchanged; committed 5B snapshots stayed byte-identical since the extant 5B test ROMs don't exercise odd envelope levels |
| Expansion-audio level — VRC7 FM (v2.1.6) | OPLL FM synth implemented (emu2413 MIT port); instrument patch ROM byte-for-byte canonical (Nuke.YKT); absolute FM level vs APU square is patch-/TL-/feedback-dependent pseudo-sine, ≈2.7× peak per Mesen2 emu2413 but not a clean square-vs-square oracle | patch_vrc7 = vrc7_all_15_melodic_patches_match_nuke_ykt_canonical unit test (real); db_vrc7/clip_vrc7 snapshot-guarded |
Patch verified; level snapshot-guarded — the FM synthesis + instrument set are correct; the absolute output level is not oracle-pinned (no square-vs-square reference for a pseudo-sine) |
| PAL APU frame-counter step positions | Modeled (v2.1.5): frame_counter.rs now clocks the PAL (2A07) sequencer at 8313/16627/24939/33252-33254 (4-step) and 8313/16627/24939/41565-41566 (5-step), region-gated by FrameCounter::pal (true only for Region::Pal; NTSC/Dendy keep 7457/14913/22371/29828-30, 37281-82). PAL scheduler timing (3.2:1, 50 Hz, PAL DMC/noise tables) was already modeled |
pal_apu_tests (10 sub-ROMs, forced PAL, on-screen verdict via run_nes_screen) |
10/10 pass — the 3 region-independent checks + the 5 PAL frame-counter-timing checks (clock jitter, mode-0/1 length timing, IRQ-flag/IRQ timing) that flipped to PASS with the PAL step positions, plus 10.len_halt_timing / 11.len_reload_timing (see the next row). NTSC byte-identity preserved (AccuracyCoin APU 141/141, apu_test 8/8, NTSC blargg_apu_2005 11/11 all unchanged). |
| Length halt/reload write-vs-half-frame-clock ordering | Modeled (v2.1.5): the length counter (length.rs) now defers a halt change (new_halt) and a length reload (reload_val + previous_count snapshot); the APU promotes both once per CPU cycle in tick_with_external, AFTER the half-frame length clock and BEFORE the mixer sample — so a halt/reload write that coincides with the clock is applied after it (halt) or dropped when the counter was clocked non-zero (reload). Mirrors TetaNES LengthCounter::reload + Mesen2 _newHaltValue. |
pal_apu_tests 10.len_halt_timing, 11.len_reload_timing (forced PAL, on-screen verdict); NTSC blargg_apu_2005 10 & 11 + f2a_* (f2_accuracy_audit.rs) |
CLOSED (v2.1.5) — both PAL ROMs now report on-screen PASSED (was FAILED: #3 / #4). Region-agnostic ordering fix: byte-identical on NTSC (the reload settles in-cycle on the common non-coincident write, and halt does not affect channel output directly), so AccuracyCoin 141/141, blargg_apu_2005 11/11 and f2_accuracy_audit 6/6 are all unchanged. |
| APU analog HPF/LPF chain | Fixed-coefficient 3-pole | No pass/fail ROM | No stricter oracle (optional measured-RC future work) |
| PlayChoice-10 Z80 second-screen menu | Not modeled | — | Out of scope |
| MMC1 software WRAM write-protect | REMEDIATED (v2.2.3 A2). MMC1 has two software PRG-RAM write-protect layers -- the $E000 bit-4 disable common to every board, and SNROM's second layer where a CHR-RAM board's CHR register bit 4 is wired to the RAM's other enable. Neither was modelled: $6000-$7FFF was read and written unconditionally |
Holy Mapperel M1_* WRAM nibble (1000 SJROM = $E000 layer; 5000 SNROM = both) |
Closed -- both layers modelled; a disabled window reports cpu_read_unmapped so the read floats to open bus rather than returning stale RAM, and writes are discarded. The CHR-register layer is gated on chr_is_ram so a CHR-ROM board still treats those bits as CHR banking. Holy Mapperel's README calls this a game-compat hazard (FCEUX / PowerPak omit it), so it was validated before landing rather than assumed: commercial oracle 60/60 (incl. 7 battery-backed MMC1 saves -- Zelda, Metroid, Final Fantasy, Mega Man 2, Castlevania II, Ninja Gaiden, Kid Icarus) and the extended corpus 138/138. Pinned by three new unit tests, incl. a negative control that a CHR-ROM board ignores the SNROM layer |
FME-7 open bus on RAM-selected-but-disabled $6000-$7FFF |
REMEDIATED (v2.2.3 A2). FME-7 models the command-$8 RAM-enable (bit 7) / RAM-select (bit 6) bits; the one unmodelled state was selected but disabled (bit 6 = 1, bit 7 = 0), which drives neither the RAM nor the ROM chip, so the databus floats. RustyNES fell through to the PRG-ROM bank and returned its tag byte 1, failing Holy Mapperel's "read open bus" sub-check (requires >= 3) and setting MAPTEST_WRAMEN |
Holy Mapperel M69_* WRAM nibble |
Closed — routed through Mapper::cpu_read_unmapped, the trait's existing "not wired to mapper-resident memory" contract, so the bus preserves whatever value the open-bus latch already holds rather than clobbering it with the ROM tag byte — the disabled window now returns open bus, not a fixed constant. (In the Holy Mapperel M69_* run that latch value is observed as $7F, which is the test's observation, not a universal result.) M69_* detail goes 1000 -> 0000. Verified by negative control (reverting flips the on-screen digit back 0 -> 1) and against the commercial oracle, where the FME-7 titles are unaffected |
| VRC7 OPLL synthesizer state carried by the mapper save state (v2.2.3 → v2.3.7) | REMEDIATED (v2.3.7). Vrc7::save_state wrote only the shadow register bytes (audio.addr_latch, audio.data_latch, audio.silenced, audio.regs[0..64]) and never the live self.opll, opll_clock_counter, or last_opll_sample; load_state never replayed them either, so after a rewind / netplay rollback / TAS restore the FM voice resumed from whatever envelope and phase state it happened to hold. rustynes_apu::Opll now exposes a snapshot/restore pair (OPLL_SNAPSHOT_VERSION 1, a fixed OPLL_SNAPSHOT_LEN) carrying the register shadow, the EG/LFO counters, the per-channel patch selection, all 18 operator slots, the user patch pair and the per-channel outputs. The lookup tables and the patch ROM are NOT carried — they are constants of construction — and chip_type rides only as a tag that rejects a cross-chip restore. The VRC7 mapper section is v2: the tail appends opll_clock_counter, last_opll_sample and that blob after the VRAM |
vrc7_save_state_carries_the_live_opll_so_audio_resumes_identically (key a note, advance 20,000 cycles, save, then compare 4,000 mixed samples from the source against 4,000 from a fresh mapper restored from the blob — equal sample for sample), plus Opll round-trip / snapshot-idempotence / cross-chip-rejection / truncation / bad-tag tests, and Opll is now registered in snapshot_schema_audit.rs so a future field cannot be added without being classified |
CLOSED (v2.3.7) — additive, so a v1 blob still loads and leaves the synthesizer exactly where the pre-v2.3.7 build left it (an old save is no worse than it was, not newly silent). A build without mapper-audio has no synthesizer to describe, so it still writes v1 and validates-then-ignores a v2 tail, preserving the cross-feature portability ADR 0004 promises. The rejected alternative is recorded because it is the one a reader will think of first: replaying audio.regs through Opll::write_reg needs no new format, but restarts every keyed-on channel at attack, so every rewind frame would produce an audible transient. Mutation-checked — making the tail carry a reset synthesizer reproduces the pre-fix failure |
2A03 die-revision "unexpected DMA" extra read (Cpu2A03Revision, ADR 0033) |
The DMC-halt-overlaps-OAM-halt "double-halt" extra parked-address re-read is revision-gated: Rp2A03G (default) performs it, Rp2A03H omits it. On this engine the gate fires (~75× in a synthetic DMC+OAM+$2007 probe) but is a no-op — the parked address during a DMC+OAM overlap is always the post-$4014 instruction fetch, never a side-effect register — so Rp2A03H is byte-identical to Rp2A03G on every oracle |
None exists — no public reference (Mesen2/ares/BizHawk/TriCNES/fceux/nestopia/GeraNES/higan) branches DMA behavior on 2A03 die stepping, and no test ROM captures it; the five dmc_dma_during_read4 ROMs + both sprdma_and_dmc_dma ROMs all Pass on the default and are the verified floor |
Frontier — documented, not closed (v2.1.7, ADR 0033). Config surface + mechanism-correct gate shipped default-off / byte-identical; the Rp2A03H direction is an unverified hypothesis; the H≡G equality is pinned by cpu_2a03_revision::rp2a03h_matches_rp2a03g_documented_residual. The reference-grounded console-type DMC-glitch axis (Mesen2 isNesBehavior) is a separate deferred knob (T-PS-dmc-glitch-console-type) |
Oracles / regression nets¶
-
Holy Mapperel bank-reachability + IRQ net (v2.1.5,
crates/rustynes-test-harness/tests/holy_mapperel.rs,--features test-roms): the 17 committed zlib-licensed ROMs (tests/roms/holy_mapperel/) each run to their settled result screen, pinned by aninstaframebuffer-hash snapshot with settled + non-blank structural guards. Catches silent mapper-detection / bank-layout / RAM-sizing / IRQ regressions theAccuracyCoin/ blargg suites don't cover; all 17 detect + reach all banks with detailed code0000as of v2.2.3 A2, which closed the last two residuals (the MMC1 WRAM write-protect and the FME-7 open-bus-on-disabled-RAM rows above). -
tests/roms/extra/apu/apu_test_{1..10}: the$4017clock that shares an APU cycle with the sequencer's step (surfaced v2.6.2, CLOSED v2.9.5). Nineteen ROMs sat in that directory referenced by nothing in the workspace. They had been dismissed as audio-output-only on the strength of a$6000probe, but$6000is unmapped on this vintage and reads back0, which is blargg's success code. That probe could not tell "passed" from "not present", the same false oracle that had made the NTSCblargg_apu_2005suite vacuous for five minor releases. Re-read with the on-screen decoder: - nine are audio-only;
- ten report a verdict, and four of those (
_1,_2,_5,_6) failed.
v2.9.5 read the ten ROMs' code, and they are one program with ten delays. Each
loads pulse 1's length counter, spends some of it with $80 writes to
$4017, sets a mode, waits almost exactly one sequencer period, writes
$4017 again, and checks whether the length counter reached zero. ROMs 9 and
10 show that the sequencer's last half-frame step happens before a reset
landing at those deltas. ROMs 1, 2, 5 and 6 then require that a mode-1
write's immediate clock, maturing one CPU cycle after that step, adds no
second decrement; one cycle later (3, 4, 7, 8) it must. The quarter- and
half-frame triggers are emitted on APU-cycle boundaries (nesdev wiki, APU
Frame Counter and its Talk page), so the two are one pulse.
FrameCounter::prev_tick_step suppresses the write's clock when the
sequencer fired the same clock on the previous tick. It is derived from the
sequencer position, so the save-state format is unchanged. Pinned by
apu_frame_clock_coincidence.rs, 10/10. Before the fix, exactly 1, 2, 5
and 6 failed. Each ROM's pass branch was decoded from its own
lda $4015 / and #$01 / beq|bne rather than trusted from the screen.
The quarter-frame half of the rule rides the same mechanism, but no ROM here observes envelopes, so it is by construction rather than pinned.
Notes¶
- PPU A12 stream, corrected (v2.9.7). The PPU reported the A12 level of
pattern fetches only, so the garbage nametable reads of the sprite window
never pulled A12 low, and each rendered line delivered one rise where the
hardware makes eight. MMC3 filters the extra seven, so every MMC3 test ROM
and AccuracyCoin passed. Boards that count raw edges were starved eightfold:
the MC-ACC (mapper 4.3), mapper 91 submapper 0 and the J.Y. ASIC's A12 mode.
Found by real games, not a test ROM. Six Acclaim MC-ACC titles run with
v2.9.6's model lost their status bars, and Alien 3 went black. Measured: 242
falls per frame reached the mapper, against the 1,928 a correct stream
gives. Fixed at the source:
Ppu::read_vramreports every read's A12. Four MMC3-core boards that clocked on every rise gained MMC3's filter (a12_filter.rs). The filter clocks onnotify_cpu_cycle, which the bus sends only to boards that declarecpu_cycle_hook. The first draft left it undeclared on those four, so their IRQ never fired on a realNeswhile their unit tests, which drove the hook by hand, passed. AV Jiu Ji Ma Jiang 2 (mapper 115) showed it. The shared test helper now asserts the flag. Changed goldens:mmc1_a12(9 pixels; SNROM's RAM enable follows CHR A12). Commercial frames that moved, each checked against v2.9.6: the Donkey Kong Country 4 world map (mapper 211), garbled before and correct now; Time Diver (mapper 250, its own IRQ fix). Mappers 115, 134 and 189 match v2.9.6 frame for frame. Pinned bya12_reports_the_hardware_stream_and_an_mmc3_filter_still_sees_241and a per-boardirq_counts_scanlines_not_raw_a12_pulses. T-COMMERCIAL-GARBLE, v2.9.7. Time Diver: Avenger (mapper 250) is fixed. The IRQ was an 8-bit M2 cycle counter, whichINES_Mapper_250.mddoes not support ("a regular MMC3 chip connected in [a] different way"). It is now the MMC3 scanline counter, and the playfield, intro and second stage render correctly. Uchuu Keibitai SDF (MMC5) is localised, not fixed. Frames 120-400 of the intro are wrong in every frame, and later screens, including its vertical-split "ROUND WAVE" screen, are correct. The intro's MMC5 state:$5100=3,$5101=3(1 KiB CHR),$5104=0(ExRAM nametable),$5105=$10;- BG set
$5128-$512B=$30-$33, 8x16 sprites ($2000=$B0); - no IRQ register written; split off (
$5200=0);$5130=0; 128 KiB CHR.
The corrupted frames show recognisable letter tiles scattered in wrong
places, so the nametable contents look displaced, not the CHR banks. The
next step is a per-access comparison against a reference run of the intro.
It stays open under the same ticket.
Fixed v2.9.9: the game reads CHR data through $2007 with 8x16 sprites
selected and the A set written last, so the MMC5 serves it from the A set;
the model always used the B set. The "displaced nametable" was correct
nametable data read from the wrong CHR bank. Closed by the MMC5 page's
$2007 rule (T-MMC5-8X8-SET below), with no game-specific code.
- MMC5 CHR bank sets, fixed v2.9.9 (T-MMC5-8X8-SET, found v2.9.7).
MMC5.md: "When using 8x8 sprites, only registers $5120-$5127 are used."
The model had used the background set for both kinds of fetch in 8x8 mode,
ignored $5101 for sprites, indexed 1 KiB banks in the 8/4/2 KiB modes and
served $2007 from the background set always. It now follows the page's
table and the hardware results the page cites (8x8: A set for everything;
8x16: B for background while rendering, last-written for $2007, A for
$2007 reads under extended attributes), learning the sprite size from its
own decode of $2000 / $2001. Five of six local MMC5 dumps are
byte-identical; Uchuu Keibitai SDF is fixed.
- Terminator 2 (MC-ACC) pause screen, unexplained. Pressing Start shows a
"PAUSED" screen overlaid with CPU-register-like text (ADC $0C15, TAX,
CLI). It looks the same under MC-ACC and standard-MMC3 timing, and the
same before and after the A12 fix, so it is not an IRQ symptom. Whether it
is the game's own pause display or an emulation fault is not established;
it is recorded rather than guessed.
-
Determinism boundary. Display-only work (the NTSC composite filter/shader ladder) stays in the frontend/shader and never feeds the framebuffer/audio golden-vector or save-state hash. Two accuracy features DO change deterministic core output when enabled — the generated NTSC palette (F1.4) and optional OAM decay (F2.3) — so both ship default-off: with the default the golden vectors / AccuracyCoin / commercial oracle / save-state hashes are byte-identical, and turning either on is a deliberate, deterministic opt-in (OAM decay is driven off the monotonic
dot_counter, never wall-clock, and its per-row state round-trips the save-state via the v7 relative-age tail). The v2.1.7 P5 hardware-revision knobs (opt-inPpuRevision::Rp2c02GOAMADDR corruption,PaletteInit::Blarggpower-up palette,PowerOnRam::Seeded/Filledwork RAM) are likewise all default-off / deterministic: at their defaults the core is byte-identical, and every configured fill is seeded or uniform (no wall-clock / OS RNG). They need no snapshot-format change — the state they touch (oam_corruption_pending,palette_ram, work RAM) is already serialized, and the selections themselves are config re-applied on load likeregion. -
NROM provides PRG-RAM at
$6000-$7FFFwhere the board has none, and the MiSTer co-simulation DUT found it (2026-08-25, v2.6.3 rung 5).crates/rustynes-mappers/src/m000_nrom.rsallocates 8 KiB of PRG-RAM unconditionally so accesses "don't fall off the edge".cpu_readreturnsself.prg_ram[addr - 0x6000]andcpu_writestores into it, so the window behaves as real, writable RAM: an unwritten location reads$00(the array is zero-filled at construction) and a written one reads back what was stored. On hardware an NROM board decodes nothing there and the read floats — it returns open bus, the last value the data bus held, whether or not the program has written to that address.
This is the emulator default nesdev_wiki/Open_bus_behavior.xhtml names as a
problem in its own words (public source:
https://www.nesdev.org/wiki/Open_bus_behavior): "A few games read the region $6000-7FFF but have no
WRAM present here. This can be a problem for emulators, as the original iNES
file format had no way to specify a lack of WRAM, leaving the emulator to
provide WRAM behavior in that region by default. The NES 2.0 format corrects
this with a WRAM size field." The page then names titles that break on the
WRAM answer — Low G Man (a crash in Chapter 1 Scene 3B, or boomerang
graphical glitches) and Battletoads & Double Dragon (a $00 read at the
end of level 1 crashes it).
Not fixed here, and the reason is scope rather than doubt. The fix is a behaviour change to the shipped emulator on every iNES-header NROM cartridge, so it needs the NES 2.0 WRAM-size field honoured where present, a decision about iNES-header defaults, the per-game database consulted, and the full accuracy battery plus the commercial-ROM oracle re-run — the same shape as the header-override hazard that froze Wizards & Warriors (ADR 0031). It is a correctness item with a citation and named symptoms, recorded rather than quietly carried.
How it was found is the point. The v2.6.3 co-simulation step wrote a
cpu_bus module from the wiki and ran it against this emulator; the DUT and
the oracle agreed on $4016, $4017, $5000 and $5C34 and disagreed only
in $6000-$7FFF. This is the first time the verification ladder has found the
oracle wrong rather than the DUT, which the v2.5.0 plan listed in advance
as risk 6: "the oracle can be wrong. 141/141 is not 'matches silicon'."
AccuracyCoin's coded passes, triaged (v2.6.5)¶
Over a 4500-frame window, where all 146 catalog entries execute, the vector
reads total=146 pass=130 pass_with_code=16 fail=0 skipped=0 not_run=0. The
sixteen are triaged here because pass_with_code is easy to read as "did not
pass cleanly", and for most of them that is simply wrong: AccuracyCoin uses the
code to say which of several accepted outcomes occurred, and for several
tests code 1 IS the canonical answer.
| entry | code | what the code means | disposition |
|---|---|---|---|
Controller Clocking |
2 → 1 | Famicom → NES / AV Famicom | FIXED v2.6.5 |
DMA + $4016 Read |
2 → 1 | Famicom → NES / AV Famicom | FIXED v2.6.5 |
Sprites On Scanline 0 |
2 | "RGB PPU Detected" | open — see below |
Implicit DMA Abort |
2 | "pre-1990 CPU" | revision selection |
APU Register Activation |
2 | the OAM-DMA bus conflict clocked the controller ports | an accepted console variant, closed v2.9.5 |
PPU Read Buffer |
16 | $41 = ASCII G — revision-G PPU |
the revision this core models |
Address $2004 behavior |
16 | $41 = ASCII G |
the revision this core models |
$93/$9F/$9B SHA/SHS |
1 | the test's FIRST success code | a clean pass |
DMA + $2002 Read |
1 | the test's FIRST success code | a clean pass |
PPU Reset Flag, CPU RAM, CPU Registers, PPU RAM, Palette RAM |
53 | — | not tests |
APU Register Activation code 2 is a console variant, not a defect. Its
finale reads $4016 twice and branches on whether the OAM-DMA bus conflict
clocked the controller port. The routine's own comment says "This used to be
an error code, but different consoles behave differently, so let's just print
if it did or not", and it returns code 1 ("OAM DMA Bus Conflict no Clock") or
code 2 ("OAM DMA Bus Conflict Clocks") as two success codes. Read from upstream
AccuracyCoin.asm (TEST_APURegActivation_Finale) in v2.9.5.
The five "code 53" entries are not tests. Each routine opens with
JSR RTS_If_Running_All_Tests and its own comment says so: "This isn't actually
testing anything anyway." They print the recorded power-on bytes on screen and
return early under RunningAllTests, so the byte left in the result slot is
whatever A held. There is no clean pass to earn.
The two "code 16" entries are a revision selection, not a defect. $41 is
ASCII G, and the ROM writes it as "Success code 'G', referring to revision G
PPU (or later) behavior" — the revision this core models. Its counterpart is
$39 = E for pre-revision-G.
Sprites On Scanline 0 was the one genuinely open item. v2.9.5 found that
the paragraph below had the wrong half open, and closed it.
The ROM (upstream AccuracyCoin.asm, TEST_Scanline0Sprites) runs two sets
of two consecutive frames:
$500/$501: is there a sprite-zero hit at X=$80on scanline 0?$502/$503: is there one at X=0?
The result then says:
- code 2, "RGB PPU": both
$500and$501hit; - code 1, composite: the two sets alternate,
40 00 00 40or00 40 40 00.
So this core already produces the scanline-0 sprite from stale secondary OAM
(the pre-render line's in-range check reads 261 & 255 = 5). What it lacks
is the alternation.
The mechanism is stated in the forum thread the ROM cites
(forums.nesdev.org/viewtopic.php?t=26291, from Visual2C02 analysis). The
sprite shifters are told to start counting on dot 339 and see that signal a dot
late. When an odd frame skips dot 340 of the pre-render line, every shifter
therefore starts scanline 0 still in the drawing state. It outputs its first
pixel at X=0, shifts, and only then begins counting, one dot late. The delayed
count cancels the extra shift, so pixels 1-7 land where they always do, and
only the first pixel moves, to X=0.
In this core that is the dot-339 re-arm (spr_halted = false) taking effect
after pixel 0 on a skipped frame. It is one piece of deferred state that lives
from pre-render dot 339 to scanline-0 dot 1. That span contains the frame
boundary, which is exactly where save states and run-ahead snapshots are taken,
so the state must be serialized. PPU_SNAPSHOT_VERSION 10 → 11 is a .rns
epoch, because the container compares the PPU section's version for equality
(ADR 0028). Implemented in v2.9.5, with the epoch taken now at the
maintainer's direction rather than deferred to v3.0.0's. The skip in
advance_dot sets spr_rearm_deferred, and emit_pixel releases the slots
after pixel 0. The PPU snapshot's v11 tail carries it, and
snapshot_v11_carries_the_deferred_sprite_rearm pins it. Sprites On
Scanline 0 now reads code 1, the only AccuracyCoin entry that moved against
v2.9.4, and the per-entry sub-test ROM moved with it.
Superseded, kept for the record (the text before v2.9.5):
The ROM reports
"RGB PPU Detected" because this core produces no sprite-zero hit at x=0 on
scanline 0. A composite 2C02 does, and the ROM cites the mechanism
(forums.nesdev.org/viewtopic.php?t=26291): the pre-render line is treated as
scanline 261 & 255 = 5 for the in-range checks during the sprite-fetch phase,
so stale secondary-OAM slots whose pixel lands on row 5 load into the shifters
for scanline 0.
Half of that is already implemented: Ppu::tick's fetch phase computes
next_line = prerender_line() & 0xFF and the shifter load gate filters on it,
both tagged for this very test. What suppresses it is in_use = slot <
spr_count: evaluation on the pre-render line runs with next_line = -1, finds
nothing, and sets spr_count = 0, so no slot reaches the load gate.
Relaxing in_use on the pre-render line alone was measured and is not
sufficient — AccuracyCoin held 141/141 and the entry stayed at code 2, because
the stale secondary-OAM content is not present either. The remaining question is
what secondary OAM holds across the pre-render line, which is sprite-evaluation
work and the item this programme's own plan names as its hardest. Deferred with
the evidence rather than attempted on a partial understanding; the experiment was
reverted, not left behind a flag.
Ignored-test dispositions¶
Every #[ignore]'d test in the workspace at v2.1.0, with its disposition (20
then; the seven permanent historical pins were deleted at v2.9.8, see below).
None is an
open accuracy gap — each is either a superseded historical pin, a by-design
revision/fixture/network limitation. The MMC3 4-scanline_timing residual
(sub-test 9 from v2.9.9's T-ORACLE-001 fix, sub-test 12 after the dot-324
step) closed with T-MMC3-BG-A12 for v3.0.0.
| Group | Count | Tests | Disposition |
|---|---|---|---|
| Permanent historical pins | 7 → 0 | APU $4015-load / reload-arm / put_cycle (apu.rs), CPU interrupt-dispatch ×3 (opcodes.rs), PPU BG-shifter (ppu.rs) |
Pinned superseded pre-master-clock unit assertions on mock buses that could never be un-ignored. Deleted at v2.9.8 (ADR 0042): the CPU three overrode the removed poll_irq / poll_nmi hooks, and all seven were records of a scheduler that no longer exists. Real coverage supersedes them: AccuracyCoin 100%, cpu_interrupts_v2 5/5 strict, visual_regression |
| MMC3 R1/R2 scanline-IRQ | 4 → 3 → 1 | mmc3_test_v1/6 (alternate-revision assertion, by design) |
Closed in v3.0.0 (T-MMC3-BG-A12): both 4-scanline_timing ROMs pass all 13 sub-tests, so their strict tests run un-ignored and their _currently_fails probes are deleted. The background fetches report A12 at the MMC3 page's dot 324, and a visible line's dot 0 drives the background CHR address except where the odd-frame skip replaced scanline 0's (NESdev PPU rendering, "Cycle 0"). Moved v2.9.9 (T-ORACLE-001) before that: the NESdev reload rule plus the IRQ output deferred to the next per-cycle hook took both ROMs from sub-test 3 to 9 and made mmc3_test_v1/5 pass. History, kept: closed for the shipping default under ADR 0002 F5.0 (2026-07-09, refined 2026-07-11); the M2-edge low-time filter tried and refuted at v2.9.5 |
| MMC3 NEC-rev-B | 1 | mmc3_test_2/6-MMC3_alt |
By-design: only one of the two opposite silicon revisions can pass; the project defaults to Sharp rev A (sub-ROM 5 passes strictly) |
| Vs. DualSystem GVS boots | 5 | vs_dualsystem boot ×4 + 1 combined-dump diagnostic |
Fixture-limited: the staged GVS dumps are the MAME maincpu half only (sub-CPU PRG absent), so boot cannot complete; needs a combined 64 KiB dual dump (see docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md) |
| Live-network probes | 2 | stun_probe, turn_probe |
Hit live public STUN / TURN servers; #[ignore]'d so CI/offline runs stay hermetic — run manually with --ignored |
| HD-pack local | 1 | hdpack |
Needs a copyrighted local HD pack via RUSTYNES_HDPACK_LOCAL |
The 13 in the last five rows are documented in docs/testing-strategy.md; the
MMC3 R1/R2 pins (4 until v2.9.9, 3 since) are dispositioned in ADR 0002, whose
2026-10-03 update records the v2.9.9 move (T-ORACLE-001) on top of the F5.0
closure, the v2.1.5 A12-phase study and the v2.9.5 filter refutation.