diff --git a/.github/release-notes/v3.1.0.md b/.github/release-notes/v3.1.0.md
new file mode 100644
index 000000000..ec39ad7ae
--- /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,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.
+
+## 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.
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 3c0046cd6..96e49f10a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,163 @@ 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.**
+ `EMULATION_EPOCH` rises from 2 to 3 (the two accuracy fixes below change bus
+ 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
+
+- **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`.
+ - **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.
+- **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
+ 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`.**
+- **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. 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). 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
+ 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 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
+ 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
+ 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,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.
+- 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: 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)
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..27e1b8884 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,8 @@
-

-

+

+

@@ -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..66528b90b 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`.
@@ -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/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-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-core/src/bus.rs b/crates/rustynes-core/src/bus.rs
index ae2de7726..7fc22ddfd 100644
--- a/crates/rustynes-core/src/bus.rs
+++ b/crates/rustynes-core/src/bus.rs
@@ -578,6 +578,50 @@ 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,
+ /// 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 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,
+ /// 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. 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
+ /// 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).
///
@@ -624,6 +668,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
@@ -955,6 +1014,12 @@ impl SystemBus {
ppu_clock: 0,
cpu_div_cached,
ppu_div_cached,
+ cpu_overclock: 1,
+ mmc3_revision_override: None,
+ cpu_div_effective: cpu_div_cached,
+ overclock_phase: 0,
+ apu_cycle: 0,
+ stock_step: true,
open_bus: 0,
internal_data_bus: 0,
last_read_addr: 0,
@@ -966,6 +1031,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,
@@ -1174,11 +1240,18 @@ 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_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;
self.dma_page = 0;
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
@@ -1222,6 +1295,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);
}
@@ -1338,6 +1417,60 @@ 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.overclock_phase = 0;
+ self.cpu_div_effective = overclock_cycle_len(self.cpu_div_cached, k, 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-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) {
+ 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`].
@@ -1639,7 +1772,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
@@ -2769,6 +2916,9 @@ 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,
+ overclock_phase: self.overclock_phase,
+ 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,
@@ -2800,6 +2950,24 @@ 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;
+ // The overclock's stock-rate position. The multiplier itself is
+ // 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_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;
self.uni_oam_halt = s.uni_oam_halt;
self.uni_oam_aligned = s.uni_oam_aligned;
@@ -3034,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`
@@ -3434,10 +3610,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();
}
}
@@ -3736,6 +3915,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)
}
@@ -3830,7 +4013,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
@@ -3847,7 +4030,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();
@@ -4188,10 +4373,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);
}
@@ -4200,7 +4401,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
@@ -4298,6 +4500,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 {
+ // 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.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).
@@ -4307,7 +4527,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
@@ -4321,6 +4541,23 @@ 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 {
+ 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,
@@ -4398,6 +4635,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
}
}
@@ -4537,6 +4776,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::*;
@@ -4719,10 +4973,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
+ // 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
+ // `[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 919c968b2..39d599f18 100644
--- a/crates/rustynes-core/src/bus_snapshot.rs
+++ b/crates/rustynes-core/src/bus_snapshot.rs
@@ -18,6 +18,23 @@
//! 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, 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_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.
+//!
+//! 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 +47,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 +151,11 @@ 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));
+ // v3.1.0 (version 3): the CPU overclock's stock-rate position.
+ w.u8(s.overclock_phase);
+ w.u64(s.apu_cycle);
w.into_vec()
}
@@ -447,6 +469,20 @@ 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_phase = r.u8()?;
+ // 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(),
+ reason: format!("CPU-overclock phase {overclock_phase} out of range"),
+ });
+ }
+ let apu_cycle = r.u64()?;
if r.remaining() != 0 {
return Err(SnapshotError::SectionInvalid {
tag: "BUS ".into(),
@@ -469,6 +505,9 @@ pub fn decode_bus(bus: &mut SystemBus, data: &[u8]) -> Result<(), SnapshotError>
four_score_idx,
four_score_sig,
dmc_halt,
+ dmc_load_write_delayed,
+ overclock_phase,
+ apu_cycle,
uni_oam_active,
uni_oam_halt,
uni_oam_aligned,
@@ -533,6 +572,16 @@ 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,
+ /// 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,
/// 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..95467e71a 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.
///
@@ -115,6 +116,10 @@ pub const EMULATION_EPOCH: u32 = 2;
/// 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,
@@ -130,6 +135,18 @@ 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. 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.
+ 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,
@@ -160,6 +177,9 @@ impl Default for HardwareOptions {
power_on_ram: PowerOnRam::default(),
power_up_palette: PaletteInit::default(),
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`.
@@ -184,6 +204,9 @@ 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(),
+ mmc3_revision: nes.mmc3_revision_override(),
four_score: nes.four_score(),
zapper_temporal_light: nes.zapper_temporal_light(),
vs_dip: nes.vs_dip(),
@@ -239,6 +262,15 @@ 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.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);
}
@@ -306,7 +338,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`), 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
@@ -338,6 +373,13 @@ 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(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);
@@ -401,6 +443,19 @@ 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 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)?;
@@ -435,6 +490,9 @@ impl HardwareOptions {
power_on_ram,
power_up_palette,
extra_scanlines,
+ cpu_overclock,
+ sprite_limit_disabled,
+ mmc3_revision,
four_score,
zapper_temporal_light,
vs_dip,
@@ -479,6 +537,12 @@ 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.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,
@@ -773,6 +837,9 @@ 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,
+ mmc3_revision: Some(rustynes_mappers::Mmc3Revision::Nec),
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..c578e72a9 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,98 @@ 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 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
+ /// 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()
+ }
+
+ /// 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 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
+ /// 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 {
@@ -4278,6 +4385,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-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-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-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs
index 4ca001d1a..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.
@@ -538,6 +558,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
@@ -1675,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,
@@ -1693,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) => {
@@ -2006,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
@@ -2112,6 +2136,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 +6861,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
@@ -9687,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).
@@ -9741,6 +9767,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();
@@ -11932,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/config.rs b/crates/rustynes-frontend/src/config.rs
index a71ed259d..7d590d9ef 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.
@@ -1875,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
@@ -1895,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
@@ -1916,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 77bc60652..efce27458 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
@@ -1931,13 +1959,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 +1992,42 @@ 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));
+ // 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()
+ } 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 +2087,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..8f2041eff 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,
@@ -1594,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;
@@ -1635,6 +1652,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;
@@ -1871,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
@@ -1884,26 +1923,74 @@ 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 && {
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();
}
- 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);
@@ -1924,6 +2011,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
@@ -2721,6 +2812,106 @@ 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"
+ );
+ }
+
+ /// 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-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs
index fcd3fbfa5..58a53919b 100644
--- a/crates/rustynes-frontend/src/i18n.rs
+++ b/crates/rustynes-frontend/src/i18n.rs
@@ -720,10 +720,18 @@ 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)");
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}?");
@@ -748,8 +756,8 @@ 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.");
- 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).");
+ 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/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/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-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-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/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-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-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/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..e71c6c487 100644
--- a/crates/rustynes-mappers/src/mapper.rs
+++ b/crates/rustynes-mappers/src/mapper.rs
@@ -325,6 +325,31 @@ 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
+ }
+
+ /// 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);
@@ -806,6 +831,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..0357043b4 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::PROTOCOL_6_SYNC_MAGIC).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,28 @@ 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`]). 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;
@@ -473,9 +501,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()?;
@@ -493,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,
@@ -501,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,
@@ -671,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());
@@ -699,6 +728,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::PROTOCOL_6_SYNC_MAGIC,
+ 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::PROTOCOL_6_SYNC_MAGIC);
+ }
+
#[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/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 190dca3bb..75bd20478 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.
//
@@ -5162,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);
@@ -5762,6 +5855,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 +5991,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 +6063,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;
@@ -6304,6 +6435,95 @@ 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. 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
+ || 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) {
@@ -6595,6 +6815,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
@@ -6756,6 +7051,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-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/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv
new file mode 100644
index 000000000..b31460365
--- /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 3
+# rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles
+accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158
+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
+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
+assorted/flowing_palette.nes 120 72990514f3e3bfb3 8a91bbbb1ead50e8 27337e564377faba 3543884
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..0bdbe4c14 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,14 +294,15 @@ 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
- /// 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,
@@ -345,8 +349,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 +443,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 +469,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 +571,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/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/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/crates/rustynes-test-harness/tests/cpu_overclock.rs b/crates/rustynes-test-harness/tests/cpu_overclock.rs
new file mode 100644
index 000000000..457131599
--- /dev/null
+++ b/crates/rustynes-test-harness/tests/cpu_overclock.rs
@@ -0,0 +1,298 @@
+//! `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};
+
+/// 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);
+ 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"
+ );
+}
+
+/// 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}"
+ );
+ }
+ }
+}
+
+/// 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
+ );
+ }
+}
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..d448217a1
--- /dev/null
+++ b/crates/rustynes-test-harness/tests/epoch_fingerprint.rs
@@ -0,0 +1,375 @@
+//! `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: "blargg/apu_mixer/triangle.nes",
+ frames: 600,
+ press_start: false,
+ reach: "a continuous tone through the mixer (audio)",
+ },
+ Probe {
+ rom: "blargg/sprite_overflow_tests/3.Timing.nes",
+ frames: 300,
+ press_start: false,
+ reach: "sprite evaluation and overflow timing",
+ },
+ Probe {
+ rom: "nes-test-roms/dmc_tests/latency.nes",
+ frames: 240,
+ press_start: false,
+ reach: "DMC fetch latency (audio)",
+ },
+ Probe {
+ rom: "blargg/mmc3_test_2/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: "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.
+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
+}
+
+/// 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();
+ 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 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. 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!(
+ 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, {} 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 \
+ 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/crates/rustynes-test-harness/tests/mmc3.rs b/crates/rustynes-test-harness/tests/mmc3.rs
index 464af2742..6c16484ce 100644
--- a/crates/rustynes-test-harness/tests/mmc3.rs
+++ b/crates/rustynes-test-harness/tests/mmc3.rs
@@ -129,8 +129,105 @@ 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"
+ );
+}
+
+/// 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 \
+ 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/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/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 f5f2d114f..e9e61775d 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,31 @@ const CHIPS: &[Chip] = &[
"ppu_div_cached",
"derived: the region's PPU master-clock divider, cached at construction",
),
+ (
+ "cpu_div_effective",
+ "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",
+ "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_phase`, `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",
+ ),
+ (
+ "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/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/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/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/STATUS.md b/docs/STATUS.md
index 5744c484d..d55a9de07 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.
@@ -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,
@@ -1881,9 +1881,9 @@ 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 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 e3732ce1b..958dced8d 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.
@@ -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). 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) |
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/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/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..c4a5ae48c 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` 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/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/apu-2a03.md b/docs/apu-2a03.md
index 29aa4561f..d46035a58 100644
--- a/docs/apu-2a03.md
+++ b/docs/apu-2a03.md
@@ -326,6 +326,25 @@ why the predicate is `put_cycle` rather than `!put_cycle`). The behavior is
end-to-end via `dmc_tests/latency.nes` (a deterministic DMC fetch-latency audio
signature) and the strictly-passing `sprdma_and_dmc_dma` alignment ROM.
+#### A load DMA refused by a write takes four cycles (v3.1.0)
+
+RDY cannot halt a write. When a pending LOAD DMA reaches the get half on which
+it would enter and that cycle is a CPU write, the load is refused and enters
+on the very next read **whichever half that is**: refused by one write it
+lands on a put half and takes four cycles (`[Put (halt)] [Get] [Put] [Get]`);
+refused by two consecutive writes it lands on a get half and takes three. The
+get-half deferral above therefore does not apply a second time to a
+write-refused load. The bus records the refusal in a one-shot latch
+(`dmc_load_write_delayed`, `crates/rustynes-core/src/bus.rs`), set in
+`Bus::write`, consumed by the DMC entry in `unified_dma_cycle_impl` and cleared
+by the next CPU read; it is in the BUS save-state section (version 3), because
+the refusing write is the last cycle of a store and a snapshot can fall between
+it and the next opcode fetch. Written from AccuracyCoin `DMA Landing on Write`
+test 9 (upstream `f5f41dc2`) and its cycle comments, and confirmed by a
+black-box per-cycle comparison with TriCNES's output at the test's
+`STA $5000`: before the fix the CPU ran the opcode fetch the hardware spends
+halted, one cycle ahead from then on.
+
### Mixer
Per `ref-docs/research-report.md` §APU Mixer, two implementations:
diff --git a/docs/compatibility.md b/docs/compatibility.md
index 6209de55b..73137556a 100644
--- a/docs/compatibility.md
+++ b/docs/compatibility.md
@@ -144,16 +144,19 @@ 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**;
-libretro + wasm + mobile presentation remain deferred; real-cabinet boot stays
-fixture-limited (maincpu-half dumps). The non-DualSystem games (Excitebike, Clu
+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 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
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
@@ -211,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`.
@@ -220,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)
@@ -320,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.**
@@ -339,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/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 46119a8aa..ecda55cfa 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
@@ -760,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
@@ -1025,8 +1049,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`;
@@ -1210,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).
@@ -2461,7 +2487,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 +2541,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/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..81119718d 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-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), 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 55eafc8d8..a93c5a690 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
@@ -279,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). |
@@ -957,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.
@@ -1008,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 (`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
+ 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..74736a16e 100644
--- a/docs/mobile-v2.9.3-run-sheet.md
+++ b/docs/mobile-v2.9.3-run-sheet.md
@@ -162,6 +162,45 @@ 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. 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 |
+| --- | --- | --- | --- | --- |
+| 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 |
+| 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
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/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/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
diff --git a/docs/ppu-2c02.md b/docs/ppu-2c02.md
index 5f63610cb..1783bb225 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
@@ -279,10 +311,27 @@ Per `ref-docs/research-report.md` §Sprite evaluation:
- **Cycles 1..=64** — clear secondary OAM to `$FF` (forced reads).
- **Cycles 65..=256** — alternate odd (read primary OAM) / even (write secondary OAM).
- Read Y from `OAM[n][m]` — `m` is normally 0, but `OAMADDR` seeds `n` and
- `m` at dot 0 (`n = (OAMADDR >> 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 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
+ 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
@@ -609,6 +658,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/docs/scheduler.md b/docs/scheduler.md
index 8945f39ec..3f027a5eb 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.
@@ -133,6 +135,39 @@ 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 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. 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.
+
+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 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
+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/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/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/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..b264df538 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,53 @@
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 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
+)
+
+
+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 +112,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 +134,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 +194,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 +236,64 @@ 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")
+
+ # ...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:
+ 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 (9 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 038fa10aa..b1f0a6ed4 100644
Binary files a/tests/roms/AccuracyCoin/mirror/AccuracyCoin-mirror.nes and b/tests/roms/AccuracyCoin/mirror/AccuracyCoin-mirror.nes differ
diff --git a/tests/roms/AccuracyCoin/mirror/README.md b/tests/roms/AccuracyCoin/mirror/README.md
index 4920cd89c..f78132e39 100644
--- a/tests/roms/AccuracyCoin/mirror/README.md
+++ b/tests/roms/AccuracyCoin/mirror/README.md
@@ -17,10 +17,10 @@ diffed entry-for-entry instead of transcribed from a photograph.
| | |
|---|---|
-| Upstream | , 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..3f099ff02 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
@@ -104,6 +120,14 @@
# offset, 0x10B5, 0x02 -> 0x03 (the `LDX #test` immediate; `LDY #suite` sits at
# 0x10AB).
#
+# 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:
#
# cargo run -p rustynes-test-harness --release --features test-roms \
@@ -124,6 +148,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
@@ -147,6 +172,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 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 000000000..ef79c9da7
Binary files /dev/null and b/tests/roms/AccuracyCoin/sub-tests/dmc-reload-timing.nes differ
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 fd87a1ff8..54242be3c 100644
Binary files a/tests/roms/AccuracyCoin/sub-tests/sprite-eval-misaligned-oam.nes and b/tests/roms/AccuracyCoin/sub-tests/sprite-eval-misaligned-oam.nes differ
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/tests/roms/accuracycoin/AccuracyCoin.nes b/tests/roms/accuracycoin/AccuracyCoin.nes
index 8ba95ee6e..26b6ac061 100644
Binary files a/tests/roms/accuracycoin/AccuracyCoin.nes and b/tests/roms/accuracycoin/AccuracyCoin.nes differ
diff --git a/tests/roms/accuracycoin/RUNTIME.md b/tests/roms/accuracycoin/RUNTIME.md
index ae4d5fde9..dd72050cd 100644
--- a/tests/roms/accuracycoin/RUNTIME.md
+++ b/tests/roms/accuracycoin/RUNTIME.md
@@ -19,9 +19,10 @@ are one directory, and two `README.md` paths collide on clone.
## Source
-`https://github.com/100thCoin/AccuracyCoin` (main branch, commit `46199ae4`,
-fetched 2026-09-19; previously `69c8860`, fetched 2026-09-11, and `71f57fb`,
-fetched 2026-05-10). Repository LICENSE is the MIT License,
+`https://github.com/100thCoin/AccuracyCoin` (main branch, commit `f5f41dc2`,
+fetched 2026-10-07 for v3.1.0, md5 `a3635c87ffb1754f58923d070548898b`;
+previously `46199ae4`, fetched 2026-09-19, `69c8860`, fetched 2026-09-11, and
+`71f57fb`, fetched 2026-05-10). Repository LICENSE is the MIT License,
"Copyright (c) 2025 Chris Siebert".
The `69c8860` -> `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/DEFERRED-AND-CARRYOVER-FEATURES.md b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md
index 55a94df67..0a0bb7358 100644
--- a/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md
+++ b/to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md
@@ -431,12 +431,20 @@ 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
-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
@@ -565,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
@@ -589,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
@@ -1065,6 +1079,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/README.md b/to-dos/README.md
index 4a1e7cbf2..5e47cffe2 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,
@@ -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 560260fa0..31ef07832 100644
--- a/to-dos/ROADMAP.md
+++ b/to-dos/ROADMAP.md
@@ -57,10 +57,10 @@ 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;
+ - 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;
@@ -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`.
@@ -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
@@ -1435,7 +1439,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.
@@ -1443,14 +1447,14 @@ 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-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 |
-| `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-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) **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 **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/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..2bef78faf 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
@@ -287,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
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
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 a6ed63983..381f8168f 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
@@ -88,4 +88,30 @@ 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 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` |
+| 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) |
+| 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
+
+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 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 |
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).