diff --git a/.github/release-notes/v2.5.1.md b/.github/release-notes/v2.5.1.md
new file mode 100644
index 00000000..7d180ea5
--- /dev/null
+++ b/.github/release-notes/v2.5.1.md
@@ -0,0 +1,43 @@
+# RustyNES v2.5.1 "Retrace"
+
+**A return address, and a gate that reported a pass it could not have earned.**
+
+Retrace is where execution goes when an interrupt finishes, and it is also what this release had to do to its own last one. **Rung 2 closes**: `tb/interrupt_sweep.py` asserts /NMI, /IRQ, or **both together** before instruction K and holds it, for every K across a hazard program, driving the identical stimulus into both sides — **60 injection points, 0 divergences** on all seven CPU fields. Getting there took three defects, and none of them was in the place I was looking.
+
+## A hardware interrupt pushed the wrong return address
+
+`RTI` returned one byte too high. The cause was **a shared block with two writers**, not the interrupt entry path. The generic operand-fetch step advances PC at `tcyc == 1` for every addressing mode except three — and `AM_BRK` was not among them. `BRK` and a hardware interrupt *share* that mode and **disagree** about it: `BRK` advances over its second byte, an interrupt does not. So for `BRK` both writers assigned `pc + 1`, same value, last one wins, no visible fault; a hardware interrupt received the generic increment with nothing overriding it.
+
+**`BRK` passing 186/186 is what kept it hidden.** The only opcode exercising `AM_BRK` was the one on which the defect was invisible, so the shared block was never examined. Rung 1 cannot see it at all — it has no interrupt stimulus — which is precisely why rung 2 exists.
+
+## The injection was wired to a dead path
+
+It first targeted `Bus::poll_nmi` / `poll_irq`. Those look like the right functions and are not the ones the production CPU uses: it samples `nmi_level()` / `irq_level()` every cycle and edge-detects itself. The oracle therefore never took an injected NMI while the DUT always did — a divergence at every single injection point, which reads as a catastrophic RTL failure and was a one-line harness error. Moving it took **IRQ 0/8 to 4/8 and NMI 0/8 to 5/8**.
+
+## A gate that measured nothing and reported the answer it wanted
+
+[ADR 0038](https://github.com/doublegate/RustyNES/blob/main/docs/adr/0038-cosim-interrupt-injection-api.md) makes the injection API conditional on a precondition: a default build must emit none of it. The ADR specified `cargo expand -p rustynes-core --lib 2>/dev/null | grep -c inject_` **must be 0**. `cargo-expand` is a separate binary and is not installed here. The redirect swallows "no such command", `grep -c` counts an empty stream, and the gate prints **the 0 it is looking for** — while measuring nothing at all. Run as written it passes on a build where the feature is fully enabled.
+
+It was caught by the control, not by reading: the same command **with the feature on** also returned 0, which is impossible if the instrument were live. Reading the OFF number first would have banked a false pass on a merge precondition. Replaced with the toolchain's own expander, and the ADR now requires reading the control first. **Measured: off = 0, on = 17.**
+
+## A published finding, retracted
+
+The previous commit reported this core's interrupt sequence as **five cycles where hardware is seven**. That is wrong. The sequence was seven cycles throughout. Both sides showed `SEI` at cycle 8; the oracle reached its handler at 17 and the DUT at 15, and `15 - 8 - 2` was read as a five-cycle sequence. In fact the DUT took the interrupt **one instruction earlier** — it never ran `SEI` — so its sequence ran 8..14 and reached the handler at `8 + 7 = 15`. Seven cycles, starting two cycles early. The cause was in the harness: `cur_instr` was `0` before the first opcode fetch, so `--nmi-at-instr 0` put cycle 0 inside the window and asserted the pin throughout the eight-cycle reset. **Two cycle numbers were differenced without checking which instruction each belonged to.** Retracted in place rather than deleted, because it was published as a defect against the RTL.
+
+## The gate has a gap, and a mutation found it
+
+Seven mutations, three outcomes, baseline captured once and verified first. Five CAUGHT. **Inverting NMI/IRQ priority came back NOT CAUGHT** — because sweeping one pin at a time, an inverted priority is indistinguishable from a correct one; nothing ever asserts both. The sweep gained a `both` mode, and the same mutation is now caught. The remaining two non-catches are explained rather than excused: NMI-with-no-recognition-delay is structurally unreachable at this rung (every injection point asserts from the *start* of an instruction, and ADR 0038's API is instruction-granular by design), and removing the dispatch-site `nmi_pending` clear changes nothing observable because the `AM_BRK` hijack window already clears it — evidence about the RTL, not about the gate.
+
+## Two v2.5.0 gates, reclassified rather than carried
+
+nestest 0-diff and the 5 M-cycle window both stop at a `$2002` read where **both sides address it** and only the data differs, because the DUT has no PPU. They are **rung-3 acceptance criteria**, not v2.5.1 debt. Carried debt implies someone dropped it; these were never reachable from here.
+
+## The programme to v2.7.0
+
+The Fabric line is delivered, so this release also lands the plan for the rest of the console — PPU, APU, mappers, MiSTer integration — with the maintainer's three decisions recorded: **both boards eventually** (DE10-Nano plus the mandatory SDRAM add-on, and a SuperStation One, with one `.rbf` booting both), **the top six mappers** (~90% of the licensed library, explicitly not FDS or expansion audio), and **v2.7.0 scoped to what genuinely fits** with the arithmetic stated up front — 20–40 weeks FTE across twenty release slots, milestones rather than 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 first de-risks the SDRAM work. Four dated `ref-docs/` research files land with it, including a hardware **source map** — a map, not a summary, because a paraphrase would become a third source that drifts from both the wiki and `docs/ppu-2c02.md`. Its citations are pinned by a new audit, which caught three bare filenames on its first run, and whose own extractor is guarded because the hand-run that preceded it reported "4 cited paths, 0 missing" against a file holding 32 — the pattern omitted `.xhtml`, the extension every real citation uses.
+
+## Verified, not asserted
+
+`rustynes-core` changes, so the accuracy numbers were re-run rather than inherited: **AccuracyCoin 141/141 (100.00%, RAM decoder)**, **nestest 0-diff**, workspace **2233 passed / 128 suites / 0 failed**. DUT side: lint 0 findings, nine opcode-group ROMs at **2115 records / 0 divergences** (`opgroup8` unchanged at 186, so `BRK` survived the change to the block it had been relying on), sweep **60/60**.
+
+**No upstream libretro/RetroArch sync**, per the amended cadence: it waits for the MiSTer core to be complete. A licence change would still override that.
diff --git a/AGENTS.md b/AGENTS.md
index 7e1ddb22..47ce578d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,7 +41,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: 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 seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. A mutation the test ROM was built to catch came back NOT CAUGHT because `TSX` leaves exactly the flags a wrongly-flagging `TXS` would compute, and a harness bug made every mutation report a catch including the baseline. The emulation core is untouched. Built on **v2.4.3 "Touchstone"** (2026-08-22) — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched. Built on **v2.4.2 "Cairn"** (2026-08-22) — the **rung-0 compare surface**. A cairn is a marker set along a route so you can tell you are still on it, which is what a rolling per-cycle hash checkpoint is. The constraint nobody budgets for in co-simulation is trace *volume*, not simulation time, and it is now **measured**: 3 frames of AccuracyCoin is 89,343 CPU cycles, **5,372,427 bytes** of `irq.csv` against **352 bytes** of `ckpt.bin` — a factor of **15,263** — so both sides chain a hash and compare every 4096 cycles, and only the divergent window is re-run with full capture. **What is hashed is a decision about hardware, not about convenience**: `CycleRecord` carries 29 fields and most are RustyNES's *model*, so `Observable` is the subset a device can genuinely produce, the IRQ pair is OR'd before hashing because hardware has one wire-OR'd /IRQ pin, and `pc` is marked DUT-observable rather than pin-observable. The emulation core is untouched. Built on **v2.4.1 "Fabric"** (2026-08-20) — the **oracle** release, opening the **v2.4.1 → v2.5.0 "Fabric"** line: a new NES core written in SystemVerilog from public hardware documentation, in a sibling repository, with this emulator as its **verification oracle**. **RustyNES is not being ported to FPGA and cannot be** — a MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream, and high-level synthesis of a cycle-accurate emulator's control flow does not produce usable hardware; what is buildable is a NEW implementation verified against this one. `crates/rustynes-cosim` is the boundary — a narrow C ABI a Verilator testbench links, plus a `nes_golden_export` CLI emitting five golden formats. **The provenance firewall extends to HDL** (ADR 0037): `NES_MiSTer` and `fpganes` `rtl/` are strict black boxes — instantiating one as an opaque testbench module to compare OUTPUTS is permitted, reading its source is not; anything unimplementable from documentation escalates to an ADR BEFORE any source is opened. Three design decisions are locked and each has a reason: **replay, not lockstep** (`Nes` exposes `run_frame()` and `step_instruction()` and nothing finer, and the determinism contract already makes a pre-recorded trace exactly the trace a lockstep run produces), **no DPI-C** (it puts `` `ifdef SIMULATION `` guards into RTL that must also pass Quartus — the construct that lets a simulated netlist drift from the synthesised one), and **hash first, capture on divergence** (a 4200-frame AccuracyCoin run is ~125 M cycles, ~7.5 GB as per-cycle CSV against ~480 KB of 4096-cycle checkpoints). The golden framebuffer is exported **pre-palette** so a palette difference cannot masquerade as a rendering one. **v2.5.0 is scoped to "the 6502 rung closes"**, not a finished core (7–13 months FTE for a full one), and two risks are accepted in writing: `NES_MiSTer` scores 121/125 on AccuracyCoin where real Famicom AV hardware also scores ~121/125, so there is no published accuracy headroom and **the core may be declined as a duplicate**; and **the oracle can be wrong**, since 141/141 is not "matches silicon" — every rung is labelled by whether it has an INDEPENDENT oracle. **The exclusion of `rustynes-cosim` from the workspace is the load-bearing detail, and it exposed a defect in the accuracy gate itself.** The crate enables `cpu-boot-trace` and `irq-timing-trace` on `rustynes-core`, and cargo unifies features across a workspace build, so as a MEMBER it made `cargo build --workspace` compile the core ONCE with the union — measured through `--message-format=json`, not inferred. `irq-timing-trace` is not an inert branch: it selects a **different** `for sub_dot in 0..3` loop in `Bus::tick_one_cpu_cycle`, so CI's `cargo test --workspace --release --features test-roms` — the accuracy battery — was validating a scheduler no user runs, the same shape as the v2.3.4 defect where the coverage harness tested a load path no user runs. The measured cost was **+1.24% / +1.39% / +1.89%** across the three `full_frame` benches, *below* this project's own 3% adoption bar, and it never touched the shipped binary or the perf gate — published precisely because it shows performance was never the argument. Exclusion has a price (an excluded package cannot use `field.workspace = true`, and `--workspace` no longer reaches it), and both halves are closed mechanically: `cosim_manifest_audit.rs` asserts every duplicated field and lint still equals the workspace's AND that the crate is still excluded (four mutations, all caught), and CI gains explicit `fmt`, `clippy` and `test` steps — the clippy step earning its place on its first run with a `must_use_candidate` `--workspace` had never surfaced. Two more findings the crate was not looking for: **the first `run_frame()` after power-on advances ZERO cycles** (the PPU is constructed at dot 340 of the pre-render line, so the seven-cycle reset ticks past the frame wrap and leaves `frame_complete` latched — gate on `Nes::frame()`, never the call count, or a `--frames 60` loop emits a 59-frame golden under a manifest claiming 60), and **no CI invocation had ever enabled `cpu-boot-trace` or `irq-timing-trace` for clippy**, so those two core modules had never passed the lint gate (six pre-existing findings; `--workspace --all-targets` covers each crate's DEFAULT feature set only). **It also carries v2.4.0 "Concordance", which merged to `main` and was never tagged**: the seven-property atomic-write sequence v2.3.9 built for `Config::save_to` is extracted into `crate::atomic_write` and adopted everywhere — the plan named three call sites and there were FOUR, the fourth being `save_state.rs`, where a truncated write is a user's game progress, while `per_game.rs` was not in the plan at all because it LOOKS correct (it renames a sibling temp file) and held two of seven: no `fsync`, and a FIXED scratch name shared across every process. Review then found **four more places the module reported success it had not earned**, each an error discarded under a comment explaining the rest of the operation: `set_permissions` swallowed (the mode applied is the one the target ALREADY had, so a failure widens a 0600 file to the umask default), the parent-directory `fsync` swallowed together with its `File::open` (so the whole barrier could be a no-op while the module's table claimed "yes", and `EIO` passed as success), a ONE-attempt occupied-scratch retry (justified by "the counter cannot repeat a name within a process", which is true and beside the point — the collision comes from a previous process whose pid was reused), and an exhaustion cleanup that deleted a file this process had not created. Plus **a `const fn` that only failed on Windows** — `is_transient_rename_error` was `const` and called `io::Error::kind`, which is not, behind `#[cfg(windows)]`, so it compiled clean on Linux and would have turned `main` red AFTER merge; the fix moved the predicate into an always-compiled function reached through `cfg!(windows) && …`, so restoring the `const` now fails on Linux. Also v2.4.0: `Nes::timeline_generation()`, a session-local counter deliberately NOT in the save state (serializing it would make a second load of the same slot restore the same generation, so a consumer would miss it — and because it lives outside the snapshot, `snapshot_schema_audit` cannot see it); the cheat save reporting its failure in the panel instead of a `stderr` nobody reads on a windowed build; and `release_anchor_audit.rs`, pinning 15 release anchors across 10 documents. It is **not** in the v2.3.9 tag — v2.3.9 corrected the eight drifted documents BY HAND, which is what its notes describe and all they claim; the standing gate merged afterwards in #427. (v2.4.1's notes as first published asserted that v2.3.9's body described the audit. It does not; that claim is retracted.) `rustynes-core` changes in both halves, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted.** Built on **v2.3.9 "Crucible"** (2026-08-20) — the **gates** release. A crucible tests something to destruction rather than inspecting it, and that is what this release does to the project's own checks: what they cover, what they only *appear* to cover, and where a regression could still reach `main` unchallenged. The v2.3.x line added five tools in four releases and the recurring finding across all of them was never that the emulation was wrong — it was that **a check reported a pass it had not earned**. **The docs-only CI skip had never worked**: `dorny/paths-filter`'s `predicate-quantifier` defaults to `some`, which includes a file if it matches ANY pattern, so the `code` filter's leading `'**'` matched everything and all seven `!` exclusions under it were DEAD from the day they were written — proven from a run rather than the docs (a one-file markdown PR logged `Filter code = true` / `Matching files: AGENTS.md`). Every documentation PR in the project's history had been running the full matrix, and that stopped being merely wasteful the day two docs-only PRs were *blocked* by an ARM cross-compile failure on jobs that should never have been scheduled. Fixed with **two** filter steps because the quantifier is step-level and the two filters need OPPOSITE settings: `code` needs `every`, while `accuracy` is a list of **alternatives** and becomes unsatisfiable under it — the naive one-line fix would have silently disabled the accuracy battery while repairing a different gate. Both directions are now observed on real PRs. **The accuracy battery now runs at review time** — `test-roms` was full-run-only, so a regression landed on `main` rather than on the PR that caused it; it is now also path-filtered over the chip crates, the core, `rustynes-gamedb` (it rewrites the iNES header on load, so it changes what the emulator *is* before a cycle runs), the harness and `tests/`, measured first at 11 of the last 40 merged PRs so ~72% still pay nothing. **Bounds were calibrated against a measurement rather than a claim**: the ARM provisioning step failed on three consecutive PRs with NO apt error in the log at all, and the real number was `Fetched 4201 kB in 4min 45s (14.7 kB/s)` — three orders of magnitude below normal, which made the previous ~40 MB package set *hopeless* rather than unlucky (~45 minutes; no timeout could have saved it); it was also installing a whole cross toolchain to obtain `libc6-dev--cross`, which the comment above it had already named, because bindgen runs the **host** clang against `--sysroot` and never invokes the cross compiler. **A freeze from one cartridge kept writing into the next** — not a stale label but an active per-frame write into the wrong game, because both memory panels' freezes feed the raw-cheat overlay applied after every frame and neither was registered with the ROM-transition hook; the sweep that closed it now covers every panel under ONE rule: **derived output is discarded, user-authored input is kept, and only input that actively *writes* is neutralised** (so RAM Search baselines and reconstructed call stacks clear, while watch lists and breakpoints survive and breakpoints stay ARMED — a breakpoint halts, visible and recoverable, where a freeze writes, silent and continuous). Two negatives are recorded because they cost time to establish: the header editor LOOKS ROM-bound and is not (it is a standalone file tool), and the event panel / trace status / HD-pixel coordinates are per-frame state or preferences. **The config file is written atomically and durably** — `fs::write` truncates then writes, and saves became automatic (closing a ROM, moving a mixer slider, finishing a Latency Oracle measurement), so an interruption left the user holding a truncated `config.toml`; seven properties, and **five came from review rather than the first draft** (sibling scratch file, `fsync` before rename, parent-directory sync, `create_new(true)` for CWE-377, mode applied at creation, symlink resolution including a **broken** link, and a pid + per-call counter — the last is what makes exclusive creation adoptable at all). **Two shipped features told the truth for the first time**: movies record TWO ports (`FrameInput` models P1 and P2) while the Replay panel printed "Four Score (P1..P4)" at the moment a user decides to press Record — widening the format is a `.rnm` epoch change, so it is disclosed at three levels with the caveat printed directly under the claim it qualifies; and a failed Latency Oracle save now says so instead of being swallowed (remembering is still NOT applying — nothing touches `run_ahead`, and an inconclusive result is not remembered at all). Also: **257 lines of dead code removed** — an APU pair (34), a closed `LockstepBus` DMA-service island (183), and `drain_dma` (40), a function called on every CPU read, every CPU write and every bus cycle whose entire body was `let _ = read_addr;` and whose comments claimed the legacy service below it "stays active for the default build" — alongside **25 of 29 `#[allow(dead_code)]` attributes suppressing nothing**, established by stripping them and re-running clippy across all EIGHT gated combinations (an item can be live by default and dead on wasm, which is precisely the case that would have earned the attribute); the **SAFETY-comment rule is now a gate** (`clippy::undocumented_unsafe_blocks` — all 91 unsafe sites already carried a justification, two had it where a human reads correctly and a checker cannot, and the lint is demonstrated to fail); and two `cargo deny` advisory ignores retired on their own stated condition (their entry said to remove them once the resolve moved past quick-xml 0.40, and it had). `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted** — and re-run AGAIN after the second round of deletions rather than only after the first. Built on **v2.3.8 "Parallax"** (2026-08-20) — **which pixels differ, not just which frame**. Parallax is the apparent shift of an object seen from two positions, and the displacement is the measurement. `Probe` could already say whether two configurations of the same ROM diverge and AT WHICH FRAME, and could say nothing about where or why: a trial reduces each frame to one `u64`, the right shape for *detecting* a difference and the wrong shape for *explaining* one — a hash says frame 412 differs and has nothing to hand to Pixel Provenance, which is where an answer actually lives. `divergence::localise` re-runs both configurations to the detected frame, keeps the full output instead of its hash, and reports the **shape** of the difference — population count, first pixel in raster order, and the inclusive bounding box — which separates kinds of bug from each other (one pixel is a sprite or a palette entry, 256 in a row is a scanline, tens of thousands is a scroll or a mode change); `is_single_scanline` is offered rather than left to call sites because the inclusive comparison is easy to get wrong. It localises on the **index** framebuffer (256x240 `u16`s of `(emphasis << 6) | colour`, the PPU's own per-pixel output before the palette lookup) — half the bytes and at least as sensitive, since the RGBA buffer is a pure function of it given the same palette. Three answers, and the third is the point: `Identical`, `Differs`, and **`Inconclusive`** for an exhausted budget or two trials that cannot be compared — the Latency Oracle precedent applies directly, "I stopped looking" must not arrive wearing the same shape as "they agree" — and the budget is checked UP FRONT for all four trials, so spending two on detection and then finding the localisation pair unaffordable cannot consume the budget that would have answered the question. Beyond locating a difference the Lens **explains** it: trial-scoped provenance capture hands a located pixel to the machinery that already answers "what wrote this, and from which instruction", and an audio lens resolves a divergence to the CPU cycle. One defect was found and fixed inside the same work — the Lens left the emulator **thirty frames ahead** of where it started, because a trial restores the anchor on the way IN and not on the way OUT (deliberate — it is what lets the Lens read the trial's final frame off `nes` directly) and the outermost caller has to put the timeline back, and did not. Cut from its own boundary commit (#407's merge) rather than from `main`, so its artifacts contain exactly the Divergence Lens. Built on **v2.3.7 "Overtone"** (2026-08-19) — the **audio-provenance** release. The APU counterpart of Pixel Provenance: a per-register write attribution answering *what wrote this, and from which instruction*, and a per-CPU-cycle mix trace answering *what were the channels actually doing* — per CPU cycle rather than per output sample, because that is the cadence at which the mix is genuinely computed, and carrying **raw** pre-mix channel values so a record describes the chip rather than the user's mixer sliders. Surfaced at **Tools → Audio → Audio Provenance**; output-only, runtime-default-off, not serialized. **Its subject is the trap it inherited.** Pixel Provenance shipped non-functional for four releases because run-ahead's per-frame rollback cleared its store after the visible frame was harvested and before the frontend released the emulator lock, so the carry landed **in the same change as the feature** here rather than after a bug report. That enumeration was then found to be incomplete: `rustynes-probe` has **three more** same-timeline restores — `Probe::run_uncounted` (once per trial, and a latency measurement runs up to **21**), `latency::measure_in_place` (the final restore, outside every per-trial guard), and the RAM Atlas panel's `TimelineGuard` — none of which used the stash, so **running the Latency Oracle or the RAM Atlas emptied both provenance panels**. Both stores are cumulative, so the records were not rebuilt by the next frame; they were gone for the session. The test named for the contract, `measure_in_place_restores_the_live_timeline`, compares `nes.snapshot()` and provenance is deliberately **not** in the snapshot — it asserted something strictly weaker than its own name and passed throughout. Closed by moving the stash into a shared `TrialGuard`, pinned by four independent mutations. **`$4014` and `$4016` were documented as attributed and were not** — the bus handles both without routing through `Apu::write_register`. **Two defects were caught by measurement rather than reading:** `apu_throughput`, built for this release, reshaped the plumbing **three times** on regressions invisible in the diff (the bench itself had to be corrected first — it omitted an end-of-cycle pair worth ~23% of true per-cycle cost), and a randomized sweep of the save-state parse boundary found **four** panics in VRC7's OPLL where hand-tracing found one, because the maximally-hostile all-`0xFF` payload set `update_requests` to all-ones and **concealed** an `eg_shift` panic. Also fixed: the **browser demo applied no per-game header corrections**, *Rad Racer*'s roadside artifact (a hybrid address spliced from a stale `v`), VRC7 save states dropping the live FM synthesizer so rewind garbled the music, and **no CI job carried a timeout** — one hung job silently skipped a release for five hours. `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted.** Built on **v2.3.6 "Sounding"** (2026-08-17) — about **measuring, and what a measurement is allowed to claim**. **Two shipped features are found never to have worked.** Pixel Provenance (the v2.3.2 marquee) returned an empty report for every user on the default `run_ahead = 1`: run-ahead's per-frame rollback is the LAST thing before the frontend releases the emulator lock, so the panel's first look was always *after* the wipe — and "click any pixel" was **never implemented** (two `DragValue` spinboxes; the only `Sense` in the file was `hover()` on a colour swatch). **Two source comments and four doc claims asserted the opposite of their own code**, which is why four releases passed unchecked. And **Duck Hunt could never score**: its protocol is "see NOTHING for one frame, then a bright spot in the next", and the light bit was sampled at end-of-frame, so a read during frame N returned frame N-1 — the probe **exactly inverted** (000000 -> 000500). Two new tools, both built to **decline rather than guess**: the **Latency Oracle** (replays one moment with a button held and without it; `None` and `Some(0)` are different answers never collapsed; `START` excluded because pausing is a reaction to a *menu*; **recommends a run-ahead depth and never applies one**) and the **RAM Atlas** (classifies all 2 KiB of work RAM, then VERIFIES a candidate by perturbing it — observation returns all 2048 labels as `Untested` so it is *structurally* incapable of claiming an effect; liveness is relative to its lens and every verdict names it; `Inert` is documented as NOT meaning unused). **APU Workstream D is CLOSED** — the 18.7%-of-frame figure stands, but it is not recoverable by gating per-cycle bookkeeping: one adoption, three measured rejections, one declined on inspection, two left unmeasured deliberately. Tools and Debug are regrouped by task (Tools had reached twenty flat entries). The core gains one `const fn` getter and nothing else, so **AccuracyCoin 141/141 and nestest 0-diff are VERIFIED, not asserted**. **NOT fixed here:** `libretro/docs#1180` (the licence on the libretro docs site) is still open upstream. Built on **v2.3.5 "Manifest"** (2026-08-16) — about **what the core declares about itself**. A user reported RetroArch still showing the pre-relicense MIT/Apache-2.0 terms. It does: RetroArch reads `dist/info/rustynes_libretro.info` from **`libretro/libretro-super`**, a SEPARATE copy from this repo's that nothing syncs and nothing compared, so the v2.2.9 GPL relicense never reached it (both upstream PRs merged 2026-07-21, exactly two weeks BEFORE the 2026-08-04 relicense). The repo-side half is corrected here — `GPLv3+`, since libretro uses short tokens and marks "or later" with a trailing `+` (tallied across all 316 upstream cores) — plus a standing `libretro_info_audit.rs` that pins the local file against the workspace manifest AND the core's own `retro_get_system_info`, making the upstream sync a **copy** rather than a re-derivation. **A licence change is now a mandatory upstream-sync trigger.** Auditing the wrapper then found **five further defects, every one with correct emulation behind it**: a hardcoded 60.0988 fps for every cartridge plus `retro_get_region` unimplemented (**PAL/Dendy ran 20.2% fast**), `retro_reset` unimplemented so **RetroArch's Reset did nothing, ever** (the library default is a literal no-op), `retro_unload_game` unimplemented (Game Genie *indices* leaked across cartridges), `aspect_ratio = 0.0` (square pixels, against the desktop frontend's 8:7), and no controller info so the **Zapper was unreachable** despite `Nes::set_zapper` being fully implemented. Review caught a **use-after-free**: RetroArch shallow-`memcpy`s the outer `retro_controller_info` array but RETAINS each `types` pointer, so the description tables must be `'static` (`SET_INPUT_DESCRIPTORS` is different and safe — never generalize between environment calls). The crate went from **zero tests to eight**. Separately the APU (**18.7% of frame time**, invisible to a symbol profile because fat LTO inlines it into `cpu_clock`) gained its first throughput bench and a default-configuration mix specialization, **−3.3% to −4.2%** on `nes_run_frame_nestest`, byte-identical by construction. Declared values are now DERIVED from `rustynes_core` constants (`FRAME_DURATION_*`, `DEFAULT_SAMPLE_RATE`) rather than transcribed. Audio stays **44,100 Hz** — a matched-normalized-frequency SFDR comparison shows 44.1k and 48k are equivalent (81.6 vs 82.2 dB), so nothing is gained, and 44,100 is the only rate this project's audio is verified at. Shipped OUTPUT byte-identical, but the APU *implementation* did change (the mix specialization is a strict specialization, not a no-op), so **AccuracyCoin 141/141 and nestest 0-diff were VERIFIED, not asserted**. **NOT fixed by that release, and since RESOLVED upstream:** RetroArch showed the wrong licence until `libretro-super#2069` merged (2026-08-16 — it now reads `GPLv3+`), and RustyNES did not appear on iOS/iPadOS/tvOS until `RetroArch#19416` merged (2026-08-16, `76f60626984a` — `rustynes` is now line 268 of `pkg/apple/update-cores.sh`, between `reminiscence` and `sameboy`). Being in the build list is not the same as being installable: it arrives with the next App Store RetroArch build, on libretro's cadence. Only `libretro/docs#1180` remains open.
+**Current release: 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 seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. A mutation the test ROM was built to catch came back NOT CAUGHT because `TSX` leaves exactly the flags a wrongly-flagging `TXS` would compute, and a harness bug made every mutation report a catch including the baseline. The emulation core is untouched. Built on **v2.4.3 "Touchstone"** (2026-08-22) — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched. Built on **v2.4.2 "Cairn"** (2026-08-22) — the **rung-0 compare surface**. A cairn is a marker set along a route so you can tell you are still on it, which is what a rolling per-cycle hash checkpoint is. The constraint nobody budgets for in co-simulation is trace *volume*, not simulation time, and it is now **measured**: 3 frames of AccuracyCoin is 89,343 CPU cycles, **5,372,427 bytes** of `irq.csv` against **352 bytes** of `ckpt.bin` — a factor of **15,263** — so both sides chain a hash and compare every 4096 cycles, and only the divergent window is re-run with full capture. **What is hashed is a decision about hardware, not about convenience**: `CycleRecord` carries 29 fields and most are RustyNES's *model*, so `Observable` is the subset a device can genuinely produce, the IRQ pair is OR'd before hashing because hardware has one wire-OR'd /IRQ pin, and `pc` is marked DUT-observable rather than pin-observable. The emulation core is untouched. Built on **v2.4.1 "Fabric"** (2026-08-20) — the **oracle** release, opening the **v2.4.1 → v2.5.0 "Fabric"** line: a new NES core written in SystemVerilog from public hardware documentation, in a sibling repository, with this emulator as its **verification oracle**. **RustyNES is not being ported to FPGA and cannot be** — a MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream, and high-level synthesis of a cycle-accurate emulator's control flow does not produce usable hardware; what is buildable is a NEW implementation verified against this one. `crates/rustynes-cosim` is the boundary — a narrow C ABI a Verilator testbench links, plus a `nes_golden_export` CLI emitting five golden formats. **The provenance firewall extends to HDL** (ADR 0037): `NES_MiSTer` and `fpganes` `rtl/` are strict black boxes — instantiating one as an opaque testbench module to compare OUTPUTS is permitted, reading its source is not; anything unimplementable from documentation escalates to an ADR BEFORE any source is opened. Three design decisions are locked and each has a reason: **replay, not lockstep** (`Nes` exposes `run_frame()` and `step_instruction()` and nothing finer, and the determinism contract already makes a pre-recorded trace exactly the trace a lockstep run produces), **no DPI-C** (it puts `` `ifdef SIMULATION `` guards into RTL that must also pass Quartus — the construct that lets a simulated netlist drift from the synthesised one), and **hash first, capture on divergence** (a 4200-frame AccuracyCoin run is ~125 M cycles, ~7.5 GB as per-cycle CSV against ~480 KB of 4096-cycle checkpoints). The golden framebuffer is exported **pre-palette** so a palette difference cannot masquerade as a rendering one. **v2.5.0 is scoped to "the 6502 rung closes"**, not a finished core (7–13 months FTE for a full one), and two risks are accepted in writing: `NES_MiSTer` scores 121/125 on AccuracyCoin where real Famicom AV hardware also scores ~121/125, so there is no published accuracy headroom and **the core may be declined as a duplicate**; and **the oracle can be wrong**, since 141/141 is not "matches silicon" — every rung is labelled by whether it has an INDEPENDENT oracle. **The exclusion of `rustynes-cosim` from the workspace is the load-bearing detail, and it exposed a defect in the accuracy gate itself.** The crate enables `cpu-boot-trace` and `irq-timing-trace` on `rustynes-core`, and cargo unifies features across a workspace build, so as a MEMBER it made `cargo build --workspace` compile the core ONCE with the union — measured through `--message-format=json`, not inferred. `irq-timing-trace` is not an inert branch: it selects a **different** `for sub_dot in 0..3` loop in `Bus::tick_one_cpu_cycle`, so CI's `cargo test --workspace --release --features test-roms` — the accuracy battery — was validating a scheduler no user runs, the same shape as the v2.3.4 defect where the coverage harness tested a load path no user runs. The measured cost was **+1.24% / +1.39% / +1.89%** across the three `full_frame` benches, *below* this project's own 3% adoption bar, and it never touched the shipped binary or the perf gate — published precisely because it shows performance was never the argument. Exclusion has a price (an excluded package cannot use `field.workspace = true`, and `--workspace` no longer reaches it), and both halves are closed mechanically: `cosim_manifest_audit.rs` asserts every duplicated field and lint still equals the workspace's AND that the crate is still excluded (four mutations, all caught), and CI gains explicit `fmt`, `clippy` and `test` steps — the clippy step earning its place on its first run with a `must_use_candidate` `--workspace` had never surfaced. Two more findings the crate was not looking for: **the first `run_frame()` after power-on advances ZERO cycles** (the PPU is constructed at dot 340 of the pre-render line, so the seven-cycle reset ticks past the frame wrap and leaves `frame_complete` latched — gate on `Nes::frame()`, never the call count, or a `--frames 60` loop emits a 59-frame golden under a manifest claiming 60), and **no CI invocation had ever enabled `cpu-boot-trace` or `irq-timing-trace` for clippy**, so those two core modules had never passed the lint gate (six pre-existing findings; `--workspace --all-targets` covers each crate's DEFAULT feature set only). **It also carries v2.4.0 "Concordance", which merged to `main` and was never tagged**: the seven-property atomic-write sequence v2.3.9 built for `Config::save_to` is extracted into `crate::atomic_write` and adopted everywhere — the plan named three call sites and there were FOUR, the fourth being `save_state.rs`, where a truncated write is a user's game progress, while `per_game.rs` was not in the plan at all because it LOOKS correct (it renames a sibling temp file) and held two of seven: no `fsync`, and a FIXED scratch name shared across every process. Review then found **four more places the module reported success it had not earned**, each an error discarded under a comment explaining the rest of the operation: `set_permissions` swallowed (the mode applied is the one the target ALREADY had, so a failure widens a 0600 file to the umask default), the parent-directory `fsync` swallowed together with its `File::open` (so the whole barrier could be a no-op while the module's table claimed "yes", and `EIO` passed as success), a ONE-attempt occupied-scratch retry (justified by "the counter cannot repeat a name within a process", which is true and beside the point — the collision comes from a previous process whose pid was reused), and an exhaustion cleanup that deleted a file this process had not created. Plus **a `const fn` that only failed on Windows** — `is_transient_rename_error` was `const` and called `io::Error::kind`, which is not, behind `#[cfg(windows)]`, so it compiled clean on Linux and would have turned `main` red AFTER merge; the fix moved the predicate into an always-compiled function reached through `cfg!(windows) && …`, so restoring the `const` now fails on Linux. Also v2.4.0: `Nes::timeline_generation()`, a session-local counter deliberately NOT in the save state (serializing it would make a second load of the same slot restore the same generation, so a consumer would miss it — and because it lives outside the snapshot, `snapshot_schema_audit` cannot see it); the cheat save reporting its failure in the panel instead of a `stderr` nobody reads on a windowed build; and `release_anchor_audit.rs`, pinning 15 release anchors across 10 documents. It is **not** in the v2.3.9 tag — v2.3.9 corrected the eight drifted documents BY HAND, which is what its notes describe and all they claim; the standing gate merged afterwards in #427. (v2.4.1's notes as first published asserted that v2.3.9's body described the audit. It does not; that claim is retracted.) `rustynes-core` changes in both halves, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted.** Built on **v2.3.9 "Crucible"** (2026-08-20) — the **gates** release. A crucible tests something to destruction rather than inspecting it, and that is what this release does to the project's own checks: what they cover, what they only *appear* to cover, and where a regression could still reach `main` unchallenged. The v2.3.x line added five tools in four releases and the recurring finding across all of them was never that the emulation was wrong — it was that **a check reported a pass it had not earned**. **The docs-only CI skip had never worked**: `dorny/paths-filter`'s `predicate-quantifier` defaults to `some`, which includes a file if it matches ANY pattern, so the `code` filter's leading `'**'` matched everything and all seven `!` exclusions under it were DEAD from the day they were written — proven from a run rather than the docs (a one-file markdown PR logged `Filter code = true` / `Matching files: AGENTS.md`). Every documentation PR in the project's history had been running the full matrix, and that stopped being merely wasteful the day two docs-only PRs were *blocked* by an ARM cross-compile failure on jobs that should never have been scheduled. Fixed with **two** filter steps because the quantifier is step-level and the two filters need OPPOSITE settings: `code` needs `every`, while `accuracy` is a list of **alternatives** and becomes unsatisfiable under it — the naive one-line fix would have silently disabled the accuracy battery while repairing a different gate. Both directions are now observed on real PRs. **The accuracy battery now runs at review time** — `test-roms` was full-run-only, so a regression landed on `main` rather than on the PR that caused it; it is now also path-filtered over the chip crates, the core, `rustynes-gamedb` (it rewrites the iNES header on load, so it changes what the emulator *is* before a cycle runs), the harness and `tests/`, measured first at 11 of the last 40 merged PRs so ~72% still pay nothing. **Bounds were calibrated against a measurement rather than a claim**: the ARM provisioning step failed on three consecutive PRs with NO apt error in the log at all, and the real number was `Fetched 4201 kB in 4min 45s (14.7 kB/s)` — three orders of magnitude below normal, which made the previous ~40 MB package set *hopeless* rather than unlucky (~45 minutes; no timeout could have saved it); it was also installing a whole cross toolchain to obtain `libc6-dev--cross`, which the comment above it had already named, because bindgen runs the **host** clang against `--sysroot` and never invokes the cross compiler. **A freeze from one cartridge kept writing into the next** — not a stale label but an active per-frame write into the wrong game, because both memory panels' freezes feed the raw-cheat overlay applied after every frame and neither was registered with the ROM-transition hook; the sweep that closed it now covers every panel under ONE rule: **derived output is discarded, user-authored input is kept, and only input that actively *writes* is neutralised** (so RAM Search baselines and reconstructed call stacks clear, while watch lists and breakpoints survive and breakpoints stay ARMED — a breakpoint halts, visible and recoverable, where a freeze writes, silent and continuous). Two negatives are recorded because they cost time to establish: the header editor LOOKS ROM-bound and is not (it is a standalone file tool), and the event panel / trace status / HD-pixel coordinates are per-frame state or preferences. **The config file is written atomically and durably** — `fs::write` truncates then writes, and saves became automatic (closing a ROM, moving a mixer slider, finishing a Latency Oracle measurement), so an interruption left the user holding a truncated `config.toml`; seven properties, and **five came from review rather than the first draft** (sibling scratch file, `fsync` before rename, parent-directory sync, `create_new(true)` for CWE-377, mode applied at creation, symlink resolution including a **broken** link, and a pid + per-call counter — the last is what makes exclusive creation adoptable at all). **Two shipped features told the truth for the first time**: movies record TWO ports (`FrameInput` models P1 and P2) while the Replay panel printed "Four Score (P1..P4)" at the moment a user decides to press Record — widening the format is a `.rnm` epoch change, so it is disclosed at three levels with the caveat printed directly under the claim it qualifies; and a failed Latency Oracle save now says so instead of being swallowed (remembering is still NOT applying — nothing touches `run_ahead`, and an inconclusive result is not remembered at all). Also: **257 lines of dead code removed** — an APU pair (34), a closed `LockstepBus` DMA-service island (183), and `drain_dma` (40), a function called on every CPU read, every CPU write and every bus cycle whose entire body was `let _ = read_addr;` and whose comments claimed the legacy service below it "stays active for the default build" — alongside **25 of 29 `#[allow(dead_code)]` attributes suppressing nothing**, established by stripping them and re-running clippy across all EIGHT gated combinations (an item can be live by default and dead on wasm, which is precisely the case that would have earned the attribute); the **SAFETY-comment rule is now a gate** (`clippy::undocumented_unsafe_blocks` — all 91 unsafe sites already carried a justification, two had it where a human reads correctly and a checker cannot, and the lint is demonstrated to fail); and two `cargo deny` advisory ignores retired on their own stated condition (their entry said to remove them once the resolve moved past quick-xml 0.40, and it had). `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted** — and re-run AGAIN after the second round of deletions rather than only after the first. Built on **v2.3.8 "Parallax"** (2026-08-20) — **which pixels differ, not just which frame**. Parallax is the apparent shift of an object seen from two positions, and the displacement is the measurement. `Probe` could already say whether two configurations of the same ROM diverge and AT WHICH FRAME, and could say nothing about where or why: a trial reduces each frame to one `u64`, the right shape for *detecting* a difference and the wrong shape for *explaining* one — a hash says frame 412 differs and has nothing to hand to Pixel Provenance, which is where an answer actually lives. `divergence::localise` re-runs both configurations to the detected frame, keeps the full output instead of its hash, and reports the **shape** of the difference — population count, first pixel in raster order, and the inclusive bounding box — which separates kinds of bug from each other (one pixel is a sprite or a palette entry, 256 in a row is a scanline, tens of thousands is a scroll or a mode change); `is_single_scanline` is offered rather than left to call sites because the inclusive comparison is easy to get wrong. It localises on the **index** framebuffer (256x240 `u16`s of `(emphasis << 6) | colour`, the PPU's own per-pixel output before the palette lookup) — half the bytes and at least as sensitive, since the RGBA buffer is a pure function of it given the same palette. Three answers, and the third is the point: `Identical`, `Differs`, and **`Inconclusive`** for an exhausted budget or two trials that cannot be compared — the Latency Oracle precedent applies directly, "I stopped looking" must not arrive wearing the same shape as "they agree" — and the budget is checked UP FRONT for all four trials, so spending two on detection and then finding the localisation pair unaffordable cannot consume the budget that would have answered the question. Beyond locating a difference the Lens **explains** it: trial-scoped provenance capture hands a located pixel to the machinery that already answers "what wrote this, and from which instruction", and an audio lens resolves a divergence to the CPU cycle. One defect was found and fixed inside the same work — the Lens left the emulator **thirty frames ahead** of where it started, because a trial restores the anchor on the way IN and not on the way OUT (deliberate — it is what lets the Lens read the trial's final frame off `nes` directly) and the outermost caller has to put the timeline back, and did not. Cut from its own boundary commit (#407's merge) rather than from `main`, so its artifacts contain exactly the Divergence Lens. Built on **v2.3.7 "Overtone"** (2026-08-19) — the **audio-provenance** release. The APU counterpart of Pixel Provenance: a per-register write attribution answering *what wrote this, and from which instruction*, and a per-CPU-cycle mix trace answering *what were the channels actually doing* — per CPU cycle rather than per output sample, because that is the cadence at which the mix is genuinely computed, and carrying **raw** pre-mix channel values so a record describes the chip rather than the user's mixer sliders. Surfaced at **Tools → Audio → Audio Provenance**; output-only, runtime-default-off, not serialized. **Its subject is the trap it inherited.** Pixel Provenance shipped non-functional for four releases because run-ahead's per-frame rollback cleared its store after the visible frame was harvested and before the frontend released the emulator lock, so the carry landed **in the same change as the feature** here rather than after a bug report. That enumeration was then found to be incomplete: `rustynes-probe` has **three more** same-timeline restores — `Probe::run_uncounted` (once per trial, and a latency measurement runs up to **21**), `latency::measure_in_place` (the final restore, outside every per-trial guard), and the RAM Atlas panel's `TimelineGuard` — none of which used the stash, so **running the Latency Oracle or the RAM Atlas emptied both provenance panels**. Both stores are cumulative, so the records were not rebuilt by the next frame; they were gone for the session. The test named for the contract, `measure_in_place_restores_the_live_timeline`, compares `nes.snapshot()` and provenance is deliberately **not** in the snapshot — it asserted something strictly weaker than its own name and passed throughout. Closed by moving the stash into a shared `TrialGuard`, pinned by four independent mutations. **`$4014` and `$4016` were documented as attributed and were not** — the bus handles both without routing through `Apu::write_register`. **Two defects were caught by measurement rather than reading:** `apu_throughput`, built for this release, reshaped the plumbing **three times** on regressions invisible in the diff (the bench itself had to be corrected first — it omitted an end-of-cycle pair worth ~23% of true per-cycle cost), and a randomized sweep of the save-state parse boundary found **four** panics in VRC7's OPLL where hand-tracing found one, because the maximally-hostile all-`0xFF` payload set `update_requests` to all-ones and **concealed** an `eg_shift` panic. Also fixed: the **browser demo applied no per-game header corrections**, *Rad Racer*'s roadside artifact (a hybrid address spliced from a stale `v`), VRC7 save states dropping the live FM synthesizer so rewind garbled the music, and **no CI job carried a timeout** — one hung job silently skipped a release for five hours. `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted.** Built on **v2.3.6 "Sounding"** (2026-08-17) — about **measuring, and what a measurement is allowed to claim**. **Two shipped features are found never to have worked.** Pixel Provenance (the v2.3.2 marquee) returned an empty report for every user on the default `run_ahead = 1`: run-ahead's per-frame rollback is the LAST thing before the frontend releases the emulator lock, so the panel's first look was always *after* the wipe — and "click any pixel" was **never implemented** (two `DragValue` spinboxes; the only `Sense` in the file was `hover()` on a colour swatch). **Two source comments and four doc claims asserted the opposite of their own code**, which is why four releases passed unchecked. And **Duck Hunt could never score**: its protocol is "see NOTHING for one frame, then a bright spot in the next", and the light bit was sampled at end-of-frame, so a read during frame N returned frame N-1 — the probe **exactly inverted** (000000 -> 000500). Two new tools, both built to **decline rather than guess**: the **Latency Oracle** (replays one moment with a button held and without it; `None` and `Some(0)` are different answers never collapsed; `START` excluded because pausing is a reaction to a *menu*; **recommends a run-ahead depth and never applies one**) and the **RAM Atlas** (classifies all 2 KiB of work RAM, then VERIFIES a candidate by perturbing it — observation returns all 2048 labels as `Untested` so it is *structurally* incapable of claiming an effect; liveness is relative to its lens and every verdict names it; `Inert` is documented as NOT meaning unused). **APU Workstream D is CLOSED** — the 18.7%-of-frame figure stands, but it is not recoverable by gating per-cycle bookkeeping: one adoption, three measured rejections, one declined on inspection, two left unmeasured deliberately. Tools and Debug are regrouped by task (Tools had reached twenty flat entries). The core gains one `const fn` getter and nothing else, so **AccuracyCoin 141/141 and nestest 0-diff are VERIFIED, not asserted**. **NOT fixed here:** `libretro/docs#1180` (the licence on the libretro docs site) is still open upstream. Built on **v2.3.5 "Manifest"** (2026-08-16) — about **what the core declares about itself**. A user reported RetroArch still showing the pre-relicense MIT/Apache-2.0 terms. It does: RetroArch reads `dist/info/rustynes_libretro.info` from **`libretro/libretro-super`**, a SEPARATE copy from this repo's that nothing syncs and nothing compared, so the v2.2.9 GPL relicense never reached it (both upstream PRs merged 2026-07-21, exactly two weeks BEFORE the 2026-08-04 relicense). The repo-side half is corrected here — `GPLv3+`, since libretro uses short tokens and marks "or later" with a trailing `+` (tallied across all 316 upstream cores) — plus a standing `libretro_info_audit.rs` that pins the local file against the workspace manifest AND the core's own `retro_get_system_info`, making the upstream sync a **copy** rather than a re-derivation. **A licence change is now a mandatory upstream-sync trigger.** Auditing the wrapper then found **five further defects, every one with correct emulation behind it**: a hardcoded 60.0988 fps for every cartridge plus `retro_get_region` unimplemented (**PAL/Dendy ran 20.2% fast**), `retro_reset` unimplemented so **RetroArch's Reset did nothing, ever** (the library default is a literal no-op), `retro_unload_game` unimplemented (Game Genie *indices* leaked across cartridges), `aspect_ratio = 0.0` (square pixels, against the desktop frontend's 8:7), and no controller info so the **Zapper was unreachable** despite `Nes::set_zapper` being fully implemented. Review caught a **use-after-free**: RetroArch shallow-`memcpy`s the outer `retro_controller_info` array but RETAINS each `types` pointer, so the description tables must be `'static` (`SET_INPUT_DESCRIPTORS` is different and safe — never generalize between environment calls). The crate went from **zero tests to eight**. Separately the APU (**18.7% of frame time**, invisible to a symbol profile because fat LTO inlines it into `cpu_clock`) gained its first throughput bench and a default-configuration mix specialization, **−3.3% to −4.2%** on `nes_run_frame_nestest`, byte-identical by construction. Declared values are now DERIVED from `rustynes_core` constants (`FRAME_DURATION_*`, `DEFAULT_SAMPLE_RATE`) rather than transcribed. Audio stays **44,100 Hz** — a matched-normalized-frequency SFDR comparison shows 44.1k and 48k are equivalent (81.6 vs 82.2 dB), so nothing is gained, and 44,100 is the only rate this project's audio is verified at. Shipped OUTPUT byte-identical, but the APU *implementation* did change (the mix specialization is a strict specialization, not a no-op), so **AccuracyCoin 141/141 and nestest 0-diff were VERIFIED, not asserted**. **NOT fixed by that release, and since RESOLVED upstream:** RetroArch showed the wrong licence until `libretro-super#2069` merged (2026-08-16 — it now reads `GPLv3+`), and RustyNES did not appear on iOS/iPadOS/tvOS until `RetroArch#19416` merged (2026-08-16, `76f60626984a` — `rustynes` is now line 268 of `pkg/apple/update-cores.sh`, between `reminiscence` and `sameboy`). Being in the build list is not the same as being installable: it arrives with the next App Store RetroArch build, on libretro's cadence. Only `libretro/docs#1180` remains open.
The prior release, **v2.3.4 "Ledger"** (2026-08-15), was the **coverage** release. Three boards land: **mapper 176 submapper 2** (WAIXING-FS005 — the `$A001` RAM Configuration Register with 32 KiB banked WRAM, the `$5000-$5FFF` register-window disable the Waixing copy-protection is built on, a mapper-195-like mixed CHR-ROM/CHR-RAM mode, two-bit `$A000` mirroring, the `$46`/`$47` bank-select swap that does NOT apply to `$06`/`$07`, PRG A21-A25, and the board's documented `$E003` decode mask), **154** (NAMCOT-3453 — mapper 88 plus a one-screen nametable bit decoded across the WHOLE `$8000-$FFFF` range, not just the bank-select window) and **243** (Sachen SA-020A — mapper 150's ASIC on its own PCB, same three registers at INVERTED significance, which is why they need separate numbers). Breadth **172 → 174 families** (51 Core + 95 Curated + 28 BestEffort). All three implemented from the NESdev wiki with **no reference-emulator source consulted**, unlike the FK23C transforms beside them which stay a disclosed Mesen2 derivation.
@@ -203,7 +203,7 @@ These cross-cutting decisions span multiple files. Reading individual chip docs
- `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 **v2.5.0 "Rungwork"** (the 6502 rung, and the two gates it cannot reach, on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed, on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject, on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead, on **v2.4.6 "Abacus"** — the core learns arithmetic, on **v2.4.5 "Compass"** — the core reaches memory, and chooses, 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, and the oracle settling a reset length our own prose gave two different answers for), on top of **v2.4.3 "Touchstone"** (2026-08-22, the two Fabric risks settled before any RTL: the Quartus 17.0.2 subset FITTED at 2 M10K blocks and 29 registers with zero synthesis warnings, and the sys/ licence audit finding ZERO GPL-2.0-only files, which inverts the plan's hedge and confirms GPL-3.0-or-later), on top of **v2.4.2 "Cairn"** (2026-08-22, the rung-0 compare surface: rolling per-cycle hash checkpoints measured at 15,263x smaller than the equivalent CSV, the acceptance gate made executable, and the partition between what RustyNES MODELS and what a device can OBSERVE), on top of **v2.4.1 "Fabric"** (2026-08-20, the oracle release opening the v2.4.1 → v2.5.0 "Fabric" line — a new NES core in SystemVerilog written from public hardware documentation in a sibling repository, with RustyNES as its VERIFICATION ORACLE; RustyNES is not being ported to FPGA and cannot be. `crates/rustynes-cosim` is the boundary (a narrow C ABI a Verilator testbench links, plus `nes_golden_export`), the firewall extends to HDL per ADR 0037 (`NES_MiSTer` and `fpganes` `rtl/` are strict black boxes), and v2.5.0 is scoped to "the 6502 rung closes" rather than a finished core. Excluding the crate from the workspace is the load-bearing detail: cargo unifies features, `irq-timing-trace` selects a DIFFERENT per-dot loop in `Bus::tick_one_cpu_cycle`, and the accuracy battery was therefore validating a scheduler no user runs. Also found: the first `run_frame()` after power-on advances ZERO cycles, and two trace-gated core modules had never been linted. It CARRIES v2.4.0 "Concordance", which merged to `main` and was never tagged — atomic durable writes on every path that persists user data (four call sites, four further silent successes found in review), `Nes::timeline_generation()`, and the 15-anchor release audit. AccuracyCoin 141/141 and nestest 0-diff VERIFIED), on top of **v2.3.9 "Crucible"** (2026-08-20, the gates release — a crucible tests to destruction rather than inspects, and this one does that to the project's own checks. The docs-only CI skip HAD NEVER WORKED [`predicate-quantifier` defaults to `some`, so the `code` filter's `'**'` matched everything and all seven `!` exclusions were dead from the day they were written]; fixed with TWO filter steps because the quantifier is step-level and `accuracy` is a list of alternatives that becomes unsatisfiable under `every` — the one-line fix would have silently disabled the accuracy battery. `test-roms` now runs at review time, path-filtered over the chip crates, the core, `rustynes-gamedb`, the harness and `tests/` [11 of the last 40 merged PRs]. A freeze from one cartridge kept writing into the next — an active per-frame write into the wrong game — closed by a ROM-transition sweep under one rule: derived output discarded, user-authored input kept, and only input that actively WRITES neutralised. The config file is written atomically and durably [seven properties, five from review]. Movies record two ports while the Replay panel advertised "Four Score (P1..P4)", now disclosed at three levels. 257 lines of dead code removed, 25 of 29 `#[allow(dead_code)]` attributes suppressing nothing, `undocumented_unsafe_blocks` made a gate, and two `cargo deny` ignores retired on their own stated condition. `rustynes-apu` and `rustynes-core` both change, so AccuracyCoin 141/141 and nestest 0-diff are VERIFIED), on top of **v2.3.8 "Parallax"** (2026-08-20, the Divergence Lens — `Probe` could say two configurations diverge and AT WHICH FRAME and nothing about where or why, because a trial reduces each frame to one `u64`; `divergence::localise` keeps the full output and reports the SHAPE of the difference [population count, first pixel in raster order, inclusive bounding box], localises on the INDEX framebuffer so a palette difference cannot masquerade as a rendering one, hands the located pixel to Pixel Provenance, and answers `Inconclusive` rather than collapsing "I stopped looking" into "they agree". Cut from its own boundary commit, so its artifacts contain exactly the Lens), on top of **v2.3.7 "Overtone"** (2026-08-19, the audio-provenance release — the APU counterpart of Pixel Provenance: a per-register write attribution [*what wrote this, and from which instruction*] plus a per-CPU-cycle mix trace [*what were the channels actually doing*], at Tools → Audio → Audio Provenance, output-only and runtime-default-off. Its real subject is the trap it inherited: Pixel Provenance shipped non-functional for four releases because run-ahead's rollback cleared its store before any UI could read it, so the carry landed in the SAME change as the feature — and then the same defect turned up in THREE more places, every restore in `rustynes-probe`, so running the Latency Oracle or the RAM Atlas silently emptied both provenance panels [the v2.3.6 fix had enumerated one caller rather than the mechanism, and `measure_in_place_restores_the_live_timeline` could not see the breach because provenance is deliberately not in the snapshot]. Two defects found by measurement not reading: the new `apu_throughput` bench reshaped the plumbing three times on regressions invisible in the diff, and a randomized sweep of the save-state parse boundary found FOUR panics in VRC7's OPLL where hand-tracing found one — the all-`0xFF` payload CONCEALED one. Also fixed: `$4014`/`$4016` documented as attributed and were not, the browser demo applied no per-game header corrections, Rad Racer's roadside artifact, VRC7 save states dropping the live FM synthesizer, and unbounded CI jobs. `rustynes-apu` and `rustynes-core` both change, so AccuracyCoin 141/141 and nestest 0-diff are VERIFIED), on top of **v2.3.6 "Sounding"** (2026-08-17, the measurement release — two shipped features found never to have worked [Pixel Provenance's record wiped by run-ahead before any UI could read it, its click never implemented; the Duck Hunt Zapper probe exactly inverted], the Latency Oracle and RAM Atlas both built to decline rather than guess, APU Workstream D closed on three measured rejections, and the Tools/Debug menus regrouped by task; core gains one `const fn` getter so AccuracyCoin 141/141 is VERIFIED), on top of **v2.3.5 "Manifest"** (2026-08-16, the declaration release — the libretro `.info` RetroArch reads is a SEPARATE upstream copy the GPL relicense never reached, corrected to `GPLv3+` with a standing audit; five wrapper defects each with correct emulation behind them [PAL 20.2% fast, Reset inert, unload leaked cheat indices, square-pixel aspect, Zapper unreachable]; a use-after-free in the controller tables found in review; the APU's first throughput bench + a −3.3%/−4.2% default-mix specialization; AccuracyCoin 141/141 VERIFIED. The RetroArch licence display and iOS/iPadOS/tvOS availability both remain blocked on upstream PRs), on top of **v2.3.4 "Ledger"** (2026-08-15, the coverage release — mappers 176/2 (WAIXING-FS005), 154 (NAMCOT-3453) and 243 (Sachen SA-020A) taking breadth to 174 families; the coverage harness moved onto the frontend's real load path, exposing a per-game-database defect that had made every Sachen cartridge unloadable since v1.2.0; this one TOUCHES the core, so AccuracyCoin 141/141 is verified, not by construction; Workstream C — the APU at 18.7% — was NOT delivered and is carried to v2.3.5), on top of **v2.3.3 "Cadence"** (2026-08-14, the display-pacing release — the run-ahead throttle oscillation traced to a stale median, a predictive engage arm, and the `wp_presentation` apparatus; frontend-only, AccuracyCoin 141/141), on top of **v2.3.2 "Lucid"** (2026-08-11, the pixel-provenance release — per-byte write attribution + the per-pixel causal record + the Tools → Pixel Provenance panel + deterministic replay attestation via `rustynes verify`; all `debug-hooks`-gated and output-only, so AccuracyCoin holds 141/141 and nestest is 0-diff), on top of **v2.3.1 "Plumb Line"** (2026-08-06, the measurement release — ten hot-path candidates measured and all ten rejected), itself on **v2.3.0 "Datum II"** (2026-08-05, the capstone closing the v2.2.6 → v2.3.0 NESdev-remediation line — **true multi-viewport OS-window detach** for every tool panel (v2.2.9's affordance only *embedded* them, so the Windows-10 trapped-window report is now genuinely fixed); a **frame-pacing fix** predating that work (the render path held the emulator lock across the blocking swapchain acquire + present, stalling frame production whenever a debugger panel was open — now split so the lock covers only the egui UI build, plus `pace_frames` reading a lock-free `has_rom` atomic instead of locking every `about_to_wait`); a **−5.13% / −3.51%** byte-identical PPU optimization (`v2.3.0 P1`: `#[inline]` on the per-dot sprite eval + hoisting the `tick_oam_bus` early-out); both remaining forum-reported accuracy items (SMB left edge, Rad Racer hybrid-address) **verified already-correct**; and the AccuracyCoin gate pinned to an **exact 141/141**), on top of **v2.2.9 "Studio II"** (2026-08-04, a frontend quality-of-life release — TAStudio piano-roll edits wired to the emulator, `.bk2` playback honoring the movie's `LogKey` column order, and a detach/pop-out affordance for tool windows (the shared `detachable_window` helper across 18 panels) [native-only; it **embedded** the panel on the single-viewport `egui_winit` integration rather than opening a separate OS window — **resolved in v2.3.0** by the real multi-viewport implementation]; frontend-only so the deterministic core is untouched and AccuracyCoin holds 141/141, nestest 0-diff), on top of **v2.2.8 "Aperture II"** (2026-08-04, a presentation-fidelity release — gamma-correct scanlines + a WebGL2 gamma fix + a sharper scanline profile; presentation-only so the pre-shader framebuffer + AccuracyCoin 141/141 are byte-identical, native default unchanged; visual verification pending), on top of **v2.2.7 "Timbre II"** (2026-08-04, an expansion-audio fidelity release — VRC6 recalibrated to ~1.0× a 2A03 pulse per the NESdev/field consensus [`VRC6_MIX_SCALE` 979→650; Mesen2's ~1.5× was the loud outlier], and the Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC; expansion-only, so the base 2A03 is byte-identical and AccuracyCoin holds 141/141), on top of **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.5.0 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio [v2.2.7, shipped], video/gamma [v2.2.8, shipped], TAS/UX [v2.2.9, shipped], and the PPU left-edge + hybrid-address accuracy capstone at **v2.3.0** "Datum II" [shipped]) is now **complete**. The freed **v2.3.0** slot is repurposed as that accuracy capstone (NOT a store launch — RustyNES is now income-free per ADR 0035; any free mobile-app store listing is a later, unversioned step with no monetization — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding.
+- 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 **v2.5.1 "Retrace"** (the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned, on **v2.5.0 "Rungwork"** — the 6502 rung, and the two gates it cannot reach, on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed, on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject, on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead, on **v2.4.6 "Abacus"** — the core learns arithmetic, on **v2.4.5 "Compass"** — the core reaches memory, and chooses, 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, and the oracle settling a reset length our own prose gave two different answers for), on top of **v2.4.3 "Touchstone"** (2026-08-22, the two Fabric risks settled before any RTL: the Quartus 17.0.2 subset FITTED at 2 M10K blocks and 29 registers with zero synthesis warnings, and the sys/ licence audit finding ZERO GPL-2.0-only files, which inverts the plan's hedge and confirms GPL-3.0-or-later), on top of **v2.4.2 "Cairn"** (2026-08-22, the rung-0 compare surface: rolling per-cycle hash checkpoints measured at 15,263x smaller than the equivalent CSV, the acceptance gate made executable, and the partition between what RustyNES MODELS and what a device can OBSERVE), on top of **v2.4.1 "Fabric"** (2026-08-20, the oracle release opening the v2.4.1 → v2.5.0 "Fabric" line — a new NES core in SystemVerilog written from public hardware documentation in a sibling repository, with RustyNES as its VERIFICATION ORACLE; RustyNES is not being ported to FPGA and cannot be. `crates/rustynes-cosim` is the boundary (a narrow C ABI a Verilator testbench links, plus `nes_golden_export`), the firewall extends to HDL per ADR 0037 (`NES_MiSTer` and `fpganes` `rtl/` are strict black boxes), and v2.5.0 is scoped to "the 6502 rung closes" rather than a finished core. Excluding the crate from the workspace is the load-bearing detail: cargo unifies features, `irq-timing-trace` selects a DIFFERENT per-dot loop in `Bus::tick_one_cpu_cycle`, and the accuracy battery was therefore validating a scheduler no user runs. Also found: the first `run_frame()` after power-on advances ZERO cycles, and two trace-gated core modules had never been linted. It CARRIES v2.4.0 "Concordance", which merged to `main` and was never tagged — atomic durable writes on every path that persists user data (four call sites, four further silent successes found in review), `Nes::timeline_generation()`, and the 15-anchor release audit. AccuracyCoin 141/141 and nestest 0-diff VERIFIED), on top of **v2.3.9 "Crucible"** (2026-08-20, the gates release — a crucible tests to destruction rather than inspects, and this one does that to the project's own checks. The docs-only CI skip HAD NEVER WORKED [`predicate-quantifier` defaults to `some`, so the `code` filter's `'**'` matched everything and all seven `!` exclusions were dead from the day they were written]; fixed with TWO filter steps because the quantifier is step-level and `accuracy` is a list of alternatives that becomes unsatisfiable under `every` — the one-line fix would have silently disabled the accuracy battery. `test-roms` now runs at review time, path-filtered over the chip crates, the core, `rustynes-gamedb`, the harness and `tests/` [11 of the last 40 merged PRs]. A freeze from one cartridge kept writing into the next — an active per-frame write into the wrong game — closed by a ROM-transition sweep under one rule: derived output discarded, user-authored input kept, and only input that actively WRITES neutralised. The config file is written atomically and durably [seven properties, five from review]. Movies record two ports while the Replay panel advertised "Four Score (P1..P4)", now disclosed at three levels. 257 lines of dead code removed, 25 of 29 `#[allow(dead_code)]` attributes suppressing nothing, `undocumented_unsafe_blocks` made a gate, and two `cargo deny` ignores retired on their own stated condition. `rustynes-apu` and `rustynes-core` both change, so AccuracyCoin 141/141 and nestest 0-diff are VERIFIED), on top of **v2.3.8 "Parallax"** (2026-08-20, the Divergence Lens — `Probe` could say two configurations diverge and AT WHICH FRAME and nothing about where or why, because a trial reduces each frame to one `u64`; `divergence::localise` keeps the full output and reports the SHAPE of the difference [population count, first pixel in raster order, inclusive bounding box], localises on the INDEX framebuffer so a palette difference cannot masquerade as a rendering one, hands the located pixel to Pixel Provenance, and answers `Inconclusive` rather than collapsing "I stopped looking" into "they agree". Cut from its own boundary commit, so its artifacts contain exactly the Lens), on top of **v2.3.7 "Overtone"** (2026-08-19, the audio-provenance release — the APU counterpart of Pixel Provenance: a per-register write attribution [*what wrote this, and from which instruction*] plus a per-CPU-cycle mix trace [*what were the channels actually doing*], at Tools → Audio → Audio Provenance, output-only and runtime-default-off. Its real subject is the trap it inherited: Pixel Provenance shipped non-functional for four releases because run-ahead's rollback cleared its store before any UI could read it, so the carry landed in the SAME change as the feature — and then the same defect turned up in THREE more places, every restore in `rustynes-probe`, so running the Latency Oracle or the RAM Atlas silently emptied both provenance panels [the v2.3.6 fix had enumerated one caller rather than the mechanism, and `measure_in_place_restores_the_live_timeline` could not see the breach because provenance is deliberately not in the snapshot]. Two defects found by measurement not reading: the new `apu_throughput` bench reshaped the plumbing three times on regressions invisible in the diff, and a randomized sweep of the save-state parse boundary found FOUR panics in VRC7's OPLL where hand-tracing found one — the all-`0xFF` payload CONCEALED one. Also fixed: `$4014`/`$4016` documented as attributed and were not, the browser demo applied no per-game header corrections, Rad Racer's roadside artifact, VRC7 save states dropping the live FM synthesizer, and unbounded CI jobs. `rustynes-apu` and `rustynes-core` both change, so AccuracyCoin 141/141 and nestest 0-diff are VERIFIED), on top of **v2.3.6 "Sounding"** (2026-08-17, the measurement release — two shipped features found never to have worked [Pixel Provenance's record wiped by run-ahead before any UI could read it, its click never implemented; the Duck Hunt Zapper probe exactly inverted], the Latency Oracle and RAM Atlas both built to decline rather than guess, APU Workstream D closed on three measured rejections, and the Tools/Debug menus regrouped by task; core gains one `const fn` getter so AccuracyCoin 141/141 is VERIFIED), on top of **v2.3.5 "Manifest"** (2026-08-16, the declaration release — the libretro `.info` RetroArch reads is a SEPARATE upstream copy the GPL relicense never reached, corrected to `GPLv3+` with a standing audit; five wrapper defects each with correct emulation behind them [PAL 20.2% fast, Reset inert, unload leaked cheat indices, square-pixel aspect, Zapper unreachable]; a use-after-free in the controller tables found in review; the APU's first throughput bench + a −3.3%/−4.2% default-mix specialization; AccuracyCoin 141/141 VERIFIED. The RetroArch licence display and iOS/iPadOS/tvOS availability both remain blocked on upstream PRs), on top of **v2.3.4 "Ledger"** (2026-08-15, the coverage release — mappers 176/2 (WAIXING-FS005), 154 (NAMCOT-3453) and 243 (Sachen SA-020A) taking breadth to 174 families; the coverage harness moved onto the frontend's real load path, exposing a per-game-database defect that had made every Sachen cartridge unloadable since v1.2.0; this one TOUCHES the core, so AccuracyCoin 141/141 is verified, not by construction; Workstream C — the APU at 18.7% — was NOT delivered and is carried to v2.3.5), on top of **v2.3.3 "Cadence"** (2026-08-14, the display-pacing release — the run-ahead throttle oscillation traced to a stale median, a predictive engage arm, and the `wp_presentation` apparatus; frontend-only, AccuracyCoin 141/141), on top of **v2.3.2 "Lucid"** (2026-08-11, the pixel-provenance release — per-byte write attribution + the per-pixel causal record + the Tools → Pixel Provenance panel + deterministic replay attestation via `rustynes verify`; all `debug-hooks`-gated and output-only, so AccuracyCoin holds 141/141 and nestest is 0-diff), on top of **v2.3.1 "Plumb Line"** (2026-08-06, the measurement release — ten hot-path candidates measured and all ten rejected), itself on **v2.3.0 "Datum II"** (2026-08-05, the capstone closing the v2.2.6 → v2.3.0 NESdev-remediation line — **true multi-viewport OS-window detach** for every tool panel (v2.2.9's affordance only *embedded* them, so the Windows-10 trapped-window report is now genuinely fixed); a **frame-pacing fix** predating that work (the render path held the emulator lock across the blocking swapchain acquire + present, stalling frame production whenever a debugger panel was open — now split so the lock covers only the egui UI build, plus `pace_frames` reading a lock-free `has_rom` atomic instead of locking every `about_to_wait`); a **−5.13% / −3.51%** byte-identical PPU optimization (`v2.3.0 P1`: `#[inline]` on the per-dot sprite eval + hoisting the `tick_oam_bus` early-out); both remaining forum-reported accuracy items (SMB left edge, Rad Racer hybrid-address) **verified already-correct**; and the AccuracyCoin gate pinned to an **exact 141/141**), on top of **v2.2.9 "Studio II"** (2026-08-04, a frontend quality-of-life release — TAStudio piano-roll edits wired to the emulator, `.bk2` playback honoring the movie's `LogKey` column order, and a detach/pop-out affordance for tool windows (the shared `detachable_window` helper across 18 panels) [native-only; it **embedded** the panel on the single-viewport `egui_winit` integration rather than opening a separate OS window — **resolved in v2.3.0** by the real multi-viewport implementation]; frontend-only so the deterministic core is untouched and AccuracyCoin holds 141/141, nestest 0-diff), on top of **v2.2.8 "Aperture II"** (2026-08-04, a presentation-fidelity release — gamma-correct scanlines + a WebGL2 gamma fix + a sharper scanline profile; presentation-only so the pre-shader framebuffer + AccuracyCoin 141/141 are byte-identical, native default unchanged; visual verification pending), on top of **v2.2.7 "Timbre II"** (2026-08-04, an expansion-audio fidelity release — VRC6 recalibrated to ~1.0× a 2A03 pulse per the NESdev/field consensus [`VRC6_MIX_SCALE` 979→650; Mesen2's ~1.5× was the loud outlier], and the Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC; expansion-only, so the base 2A03 is byte-identical and AccuracyCoin holds 141/141), on top of **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.5.1 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio [v2.2.7, shipped], video/gamma [v2.2.8, shipped], TAS/UX [v2.2.9, shipped], and the PPU left-edge + hybrid-address accuracy capstone at **v2.3.0** "Datum II" [shipped]) is now **complete**. The freed **v2.3.0** slot is repurposed as that accuracy capstone (NOT a store launch — RustyNES is now income-free per ADR 0035; any free mobile-app store listing is a later, unversioned step with no monetization — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding.
- **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.39.0`). The local `markdownlint` binary is a newer version that reports rules v0.39.0 lacks (e.g. MD060) — those are NOT gated; verify with `pre-commit run markdownlint --all-files`, not the bare binary. `.markdownlint.json` 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 0b7130fe..2a3986f6 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -2,7 +2,7 @@
**Document Version:** 2.1.0
**Last Updated:** 2026-08-20
-**Applies to:** RustyNES v2.5.0 (the scheduling model is v2.0.0 "Timebase" onward)
+**Applies to:** RustyNES v2.5.1 (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 6a163758..802f5105 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,81 @@ cycle-accurate core later replaced.
## [Unreleased]
+## [2.5.1] - 2026-08-23 - "Retrace" (a return address, and a gate that reported a pass it could not have earned)
+
+### Added
+
+- **The interrupt sweep, and rung 2 closes** (`RustyNES_MiSTer@9425d73`).
+ `tb/interrupt_sweep.py` asserts /NMI, /IRQ, or **both together** before
+ instruction K and holds it, for every K across a hazard program, driving
+ identical stimulus into both sides: **60 injection points, 0 divergences** on
+ all seven CPU fields (20 instructions x three pin configurations).
+- **The core-side injection API** ([ADR 0038](docs/adr/0038-cosim-interrupt-injection-api.md)),
+ behind a default-off `cosim-interrupt-inject` feature that only the excluded
+ `rustynes-cosim` crate enables. `Nes::inject_nmi` / `inject_irq` drive the
+ **level functions** the CPU actually samples, and `Oracle::run_with_injection`
+ steps instructions while toggling the pins.
+- **`mister_source_map_audit.rs`**, pinning every citation in the new hardware
+ **source map** to a file that exists. Under the firewall those pages are the
+ *only* permitted sources, so a dangling citation is a behaviour with no source.
+- **The v2.5.1 -> v2.7.0 programme** ([`to-dos/plans/v2.7.0-mister-core-plan.md`](to-dos/plans/v2.7.0-mister-core-plan.md)),
+ four dated `ref-docs/` research files, and `to-dos/mister/`.
+
+### Fixed
+
+- **A hardware interrupt pushed the wrong return address.** `RTI` returned one
+ byte too high. The cause was a **shared block with two writers**: the generic
+ operand-fetch step advances PC at `tcyc == 1` for every addressing mode except
+ three, and `AM_BRK` was not among them. `BRK` and a hardware interrupt *share*
+ that mode and disagree about it -- `BRK` advances over its second byte, an
+ interrupt does not -- so for `BRK` both writers assigned the same value and the
+ fault was invisible. **`BRK` passing 186/186 is what kept it hidden**: the only
+ opcode exercising the mode was the one on which the defect did not show.
+- **The injection was wired to a dead path.** It first targeted `Bus::poll_nmi` /
+ `poll_irq`, which look like the right functions and are not the ones the
+ production CPU uses -- it samples `nmi_level()` / `irq_level()` every cycle and
+ edge-detects itself. The oracle never took an injected NMI while the DUT always
+ did. Moving it took **IRQ 0/8 to 4/8 and NMI 0/8 to 5/8**.
+- **Second-to-last-cycle interrupt recognition** in the DUT, per the documented
+ rule. It did not move the sweep's numbers and is in because the rule says so;
+ said plainly rather than credited with a fix it did not make.
+- **`mutate.sh` announced a baseline it had not captured.** Sourced from a
+ non-bash shell, `BASH_SOURCE` was unset and `ROOT` resolved to `/`; the `cp`
+ failed and the next line still printed "captured baseline". It now fails there,
+ and the echo is joined to the copy with `&&`.
+
+### Changed
+
+- **ADR 0038's own gate 2a reported a false pass.** As written it piped
+ `cargo expand` -- a separate binary, not installed here -- through
+ `2>/dev/null | grep -c inject_`, so it counted an empty stream and printed the
+ **0 it was looking for** while measuring nothing. Caught by the control, not by
+ reading: the same command with the feature *enabled* also returned 0. Replaced
+ with the toolchain's own expander, and the ADR now requires reading the control
+ first. **Measured: off = 0, on = 17.**
+- **The sweep gained its both-pins case because a mutation found the gap.**
+ Inverting NMI/IRQ priority came back NOT CAUGHT: sweeping one pin at a time,
+ an inverted priority is indistinguishable from a correct one. It is caught now.
+- **A published finding is retracted.** The previous commit reported this core's
+ interrupt sequence as **five cycles where hardware is seven**. It was seven
+ throughout; two cycle numbers were differenced without checking which
+ instruction each belonged to. The real fault was in the harness -- `cur_instr`
+ was `0` before the first opcode fetch, so `--nmi-at-instr 0` asserted the pin
+ throughout the eight-cycle reset. Retracted in place rather than deleted,
+ because it was published as a defect against the RTL.
+- **nestest 0-diff and the 5 M-cycle window are reclassified, not carried.** Both
+ stop at a `$2002` read where *both sides address it* and only the data differs
+ -- the DUT has no PPU. They are rung-3 acceptance criteria.
+
+### Verified
+
+`rustynes-core` changes (the feature gate, its fields and setters), so the
+accuracy numbers are **verified, not asserted**: **AccuracyCoin 141/141 (100.00%,
+RAM decoder)**, **nestest 0-diff**, workspace **2233 passed / 128 suites / 0
+failed**. DUT side: lint 0 findings, nine opcode-group ROMs **2115 records / 0
+divergences** (`opgroup8` unchanged at 186), sweep **60/60**. Seven mutations
+against the sweep: five CAUGHT, two NOT CAUGHT and both explained.
+
## [2.5.0] - 2026-08-23 - "Rungwork" (the 6502 rung, and the two gates it cannot reach)
### Added
diff --git a/Cargo.lock b/Cargo.lock
index 866156ca..5791a1bd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4290,7 +4290,7 @@ dependencies = [
[[package]]
name = "rustynes-android"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"android-activity",
"android_logger",
@@ -4308,7 +4308,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "rustynes-cheevos"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"cc",
"ureq",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "rustynes-frontend"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"anstyle",
"arboard",
@@ -4416,18 +4416,18 @@ dependencies = [
[[package]]
name = "rustynes-gamedb"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"rustynes-core",
]
[[package]]
name = "rustynes-gfx-shaders"
-version = "2.5.0"
+version = "2.5.1"
[[package]]
name = "rustynes-hdpack"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"lewton",
"png",
@@ -4438,7 +4438,7 @@ dependencies = [
[[package]]
name = "rustynes-ios"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bytemuck",
"cpal",
@@ -4452,7 +4452,7 @@ dependencies = [
[[package]]
name = "rustynes-libretro"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"libc",
"rust-libretro",
@@ -4461,7 +4461,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4473,7 +4473,7 @@ dependencies = [
[[package]]
name = "rustynes-mobile"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"rustynes-core",
"rustynes-hdpack",
@@ -4488,7 +4488,7 @@ dependencies = [
[[package]]
name = "rustynes-netplay"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"futures-util",
"js-sys",
@@ -4504,7 +4504,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4516,21 +4516,21 @@ dependencies = [
[[package]]
name = "rustynes-probe"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"rustynes-core",
]
[[package]]
name = "rustynes-ra"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"rustynes-cheevos",
]
[[package]]
name = "rustynes-script"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"mlua",
"piccolo",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "rustynes-test-harness"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"insta",
"png",
diff --git a/Cargo.toml b/Cargo.toml
index eee42c0f..0b021352 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 = "2.5.0"
+version = "2.5.1"
edition = "2024"
rust-version = "1.96"
license = "GPL-3.0-or-later"
diff --git a/OVERVIEW.md b/OVERVIEW.md
index c55b59d5..203ef651 100644
--- a/OVERVIEW.md
+++ b/OVERVIEW.md
@@ -2,7 +2,7 @@
**Document Version:** 2.1.0
**Last Updated:** 2026-08-23
-**Applies to:** RustyNES v2.5.0
+**Applies to:** RustyNES v2.5.1
---
@@ -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 one deliberate breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v2.5.0 "Rungwork"**. 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 one deliberate breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v2.5.1 "Retrace"**. 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 **v2.5.0**.
+> 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 **v2.5.1**.
---
diff --git a/README.md b/README.md
index dca7c6ce..6fd97334 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-

+


@@ -668,7 +668,7 @@ and the Material-for-MkDocs documentation handbook at
## Current Release
-RustyNES's current release is **v2.5.0 "Rungwork"** — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** — the core learns arithmetic. Built on **v2.4.5 "Compass"** — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** — the first real RTL. The 6502's eight-cycle reset and the seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. A mutation the test ROM was built to catch came back NOT CAUGHT because `TSX` leaves exactly the flags a wrongly-flagging `TXS` would compute, and a harness bug made every mutation report a catch including the baseline. The emulation core is untouched. It builds on **v2.4.3 "Touchstone"** — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched.
+RustyNES's current release is **v2.5.1 "Retrace"** — the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned. Built on **v2.5.0 "Rungwork"** — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** — the core learns arithmetic. Built on **v2.4.5 "Compass"** — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** — the first real RTL. The 6502's eight-cycle reset and the seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. A mutation the test ROM was built to catch came back NOT CAUGHT because `TSX` leaves exactly the flags a wrongly-flagging `TXS` would compute, and a harness bug made every mutation report a catch including the baseline. The emulation core is untouched. It builds on **v2.4.3 "Touchstone"** — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched.
It builds on **v2.4.2 "Cairn"** — the **rung-0 compare surface**. A cairn is a marker set along a route so you can tell you are still on it, which is what a rolling per-cycle hash checkpoint is. The constraint nobody budgets for in co-simulation is trace *volume*, not simulation time, and it is now **measured**: 3 frames of AccuracyCoin is 89,343 CPU cycles, **5,372,427 bytes** of `irq.csv` against **352 bytes** of `ckpt.bin` — a factor of **15,263** — so both sides chain a hash and compare every 4096 cycles, and only the divergent window is re-run with full capture. **What is hashed is a decision about hardware, not about convenience**: `CycleRecord` carries 29 fields and most are RustyNES's *model*, so `Observable` is the subset a device can genuinely produce, the IRQ pair is OR'd before hashing because hardware has one wire-OR'd /IRQ pin, and `pc` is marked DUT-observable rather than pin-observable. The emulation core is untouched.
diff --git a/ROADMAP.md b/ROADMAP.md
index e3045b88..d85fd192 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,13 +2,13 @@
**Document Version:** 2.0.4
**Last Updated:** 2026-08-23
-**Project Status:** v2.5.0 "Rungwork" released — the current head of the line, on **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", on the v2.0.0 "Timebase" MAJOR cut. **This file is a historical snapshot of the v1.0.0 cut**; see [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) for the authoritative forward roadmap and [`docs/STATUS.md`](docs/STATUS.md) for current state.
+**Project Status:** v2.5.1 "Retrace" released — the current head of the line, on **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", on the v2.0.0 "Timebase" MAJOR cut. **This file is a historical snapshot of the v1.0.0 cut**; see [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) for the authoritative forward roadmap and [`docs/STATUS.md`](docs/STATUS.md) for current state.
---
## Where we are
-RustyNES is well past v1.0.0. The current release is **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 of the co-simulation programme, on **v2.4.3 "Touchstone"** (2026-08-22), the two Fabric risks settled before any RTL, on **v2.4.2 "Cairn"** (2026-08-22), the rung-0 compare surface, on **v2.4.1 "Fabric"** (2026-08-20), the oracle release opening the v2.4.1 → v2.5.0 "Fabric" line, and carrying the never-tagged v2.4.0 "Concordance", sitting atop **v2.0.0 "Timebase"** (2026-07-03), the designated MAJOR cut that replaced the PPU-dot lockstep scheduler with the one-clock / every-cycle-bus-access model. Since then the **v2.0.x "Harbor"** mobile-finalization train, the **v2.1.x "Fathom"** accuracy line, the **v2.2.0 "Capstone"** milestone, the **v2.2.6 → v2.3.0** de-monetization + NESdev-remediation line, and the **v2.3.1 → v2.3.9** measurement / tooling / gates line have all shipped. Between v1.0.0 and v2.0.0 the additive v1.x line delivered deep desktop tooling and three whole new platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core); the v2.0.x train then re-ported mobile onto the new core and, at **v2.0.3**, promoted the 2-cycle-ALE PPU fetch model to the default to reach **AccuracyCoin 100% (141/141)**.
+RustyNES is well past v1.0.0. The current release is **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 of the co-simulation programme, on **v2.4.3 "Touchstone"** (2026-08-22), the two Fabric risks settled before any RTL, on **v2.4.2 "Cairn"** (2026-08-22), the rung-0 compare surface, on **v2.4.1 "Fabric"** (2026-08-20), the oracle release opening the v2.4.1 → v2.5.0 "Fabric" line, and carrying the never-tagged v2.4.0 "Concordance", sitting atop **v2.0.0 "Timebase"** (2026-07-03), the designated MAJOR cut that replaced the PPU-dot lockstep scheduler with the one-clock / every-cycle-bus-access model. Since then the **v2.0.x "Harbor"** mobile-finalization train, the **v2.1.x "Fathom"** accuracy line, the **v2.2.0 "Capstone"** milestone, the **v2.2.6 → v2.3.0** de-monetization + NESdev-remediation line, and the **v2.3.1 → v2.3.9** measurement / tooling / gates line have all shipped. Between v1.0.0 and v2.0.0 the additive v1.x line delivered deep desktop tooling and three whole new platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core); the v2.0.x train then re-ported mobile onto the new core and, at **v2.0.3**, promoted the 2-cycle-ALE PPU fetch model to the default to reach **AccuracyCoin 100% (141/141)**.
**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 dba940b8..7d563a86 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,7 +2,7 @@
## Supported Versions
-The current release is **v2.5.0 "Rungwork"**, on **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 **v2.5.1 "Retrace"**, on **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a
rolling patch cadence rather than maintaining long-lived release branches, so
security fixes land in the next patch release rather than being backported.
Report against the latest release or `main`.
diff --git a/SUPPORT.md b/SUPPORT.md
index ec2f83df..97ec5544 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -94,7 +94,7 @@ 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 **v2.5.0 "Rungwork"** (the 6502 rung, and the two gates it cannot reach, on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed, on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject, on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead, on **v2.4.6 "Abacus"** — the core learns arithmetic, on **v2.4.5 "Compass"** — the core reaches memory, and chooses, on **v2.4.4 "Ignition"** — the first real RTL of the co-simulation programme, on v2.4.3 "Touchstone", the two Fabric risks settled before any RTL, on v2.4.2 "Cairn", the rung-0 compare surface of the v2.4.1 → v2.5.0 "Fabric" line, on v2.4.1 "Fabric" and the never-tagged v2.4.0 "Concordance", atop the v2.0.0 "Timebase" one-clock scheduler base), a complete, playable desktop application plus native Android / iOS / Libretro builds and a browser build. See [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) for what shipped and the forward directions.
+A: Yes. RustyNES is well past its first stable release — the current release is **v2.5.1 "Retrace"** (the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned, on **v2.5.0 "Rungwork"** — the 6502 rung, and the two gates it cannot reach, on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed, on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject, on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead, on **v2.4.6 "Abacus"** — the core learns arithmetic, on **v2.4.5 "Compass"** — the core reaches memory, and chooses, on **v2.4.4 "Ignition"** — the first real RTL of the co-simulation programme, on v2.4.3 "Touchstone", the two Fabric risks settled before any RTL, on v2.4.2 "Cairn", the rung-0 compare surface of the v2.4.1 → v2.5.0 "Fabric" line, on v2.4.1 "Fabric" and the never-tagged v2.4.0 "Concordance", atop the v2.0.0 "Timebase" one-clock scheduler base), a complete, playable desktop application plus native Android / iOS / Libretro builds and a browser build. See [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) for what shipped and the forward directions.
**Q: How accurate is RustyNES?**
diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md
index 906c6a6f..8513c5f9 100644
--- a/VERSION-PLAN.md
+++ b/VERSION-PLAN.md
@@ -1,6 +1,6 @@
# RustyNES Version Plan
-**Current release: v2.5.0 "Rungwork"** — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** — the core learns arithmetic. Built on **v2.4.5 "Compass"** — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** — the first real RTL. The 6502's eight-cycle reset and the seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. A mutation the test ROM was built to catch came back NOT CAUGHT because `TSX` leaves exactly the flags a wrongly-flagging `TXS` would compute, and a harness bug made every mutation report a catch including the baseline. The emulation core is untouched. Built on **v2.4.3 "Touchstone"** — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched. Built on **v2.4.2 "Cairn"** — the **rung-0 compare surface**. A cairn is a marker set along a route so you can tell you are still on it, which is what a rolling per-cycle hash checkpoint is. The constraint nobody budgets for in co-simulation is trace *volume*, not simulation time, and it is now **measured**: 3 frames of AccuracyCoin is 89,343 CPU cycles, **5,372,427 bytes** of `irq.csv` against **352 bytes** of `ckpt.bin` — a factor of **15,263** — so both sides chain a hash and compare every 4096 cycles, and only the divergent window is re-run with full capture. **What is hashed is a decision about hardware, not about convenience**: `CycleRecord` carries 29 fields and most are RustyNES's *model*, so `Observable` is the subset a device can genuinely produce, the IRQ pair is OR'd before hashing because hardware has one wire-OR'd /IRQ pin, and `pc` is marked DUT-observable rather than pin-observable. The emulation core is untouched. Built on **v2.4.1 "Fabric"** — RustyNES as the oracle a new implementation is verified against. It opens the **v2.4.1 → v2.5.0 "Fabric"** line: a new NES core in SystemVerilog, written from public hardware documentation in a sibling repository, with this emulator as its verification oracle. RustyNES is not being ported to FPGA and cannot be; `crates/rustynes-cosim` is the boundary, and the firewall extends to HDL (ADR 0037). **v2.5.0 is scoped to "the 6502 rung closes"**, not a finished core. Excluding the crate from the workspace is the load-bearing detail — cargo unifies features, and `irq-timing-trace` selects a *different* per-dot loop in `Bus::tick_one_cpu_cycle`, so the accuracy battery had been validating a scheduler no user runs. It also carries **v2.4.0 "Concordance"**, which merged to `main` and was never tagged: atomic durable writes on every path that persists user data, `Nes::timeline_generation()`, and the 15-anchor release audit. AccuracyCoin **141/141** and nestest 0-diff verified, not asserted. Previously, **v2.3.9 "Crucible"** — what the gates actually cover. A crucible tests to destruction rather than inspects, and this release does that to the project's own checks. **The docs-only CI skip had never worked**: `predicate-quantifier` defaults to `some`, so the `code` filter's leading `'**'` matched everything and all seven `!` exclusions under it were dead from the day they were written — fixed with **two** filter steps, because the quantifier is step-level and `accuracy` is a list of *alternatives* that becomes unsatisfiable under `every`, so the one-line fix would have silently disabled the accuracy battery while repairing a different gate. **`test-roms` now runs at review time**, path-filtered over the chip crates, the core, `rustynes-gamedb`, the harness and `tests/` (11 of the last 40 merged PRs, so ~72% still pay nothing). **A freeze from one cartridge kept writing into the next** — an active per-frame write into the wrong game — closed by a ROM-transition sweep across every panel under one rule: derived output discarded, user-authored input kept, and only input that actively *writes* neutralised. **The config file is written atomically and durably** (seven properties, five from review rather than the first draft). Plus 257 lines of dead code removed, 25 of 29 `#[allow(dead_code)]` attributes found to suppress nothing, `undocumented_unsafe_blocks` made a gate, and two `cargo deny` ignores retired on their own stated condition. `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 and nestest 0-diff are verified, not asserted**. Built on **v2.3.8 "Parallax"** — which pixels differ, not just which frame: `Probe` could say two configurations of the same ROM diverge and *at which frame* and nothing about where or why, because a trial reduces each frame to one `u64`. The **Divergence Lens** keeps the full output instead of its hash and reports the *shape* of the difference (population count, first pixel in raster order, inclusive bounding box), localises on the **index** framebuffer so a palette difference cannot masquerade as a rendering one, hands the located pixel to Pixel Provenance so the answer is a cause rather than a coordinate, and answers `Inconclusive` rather than letting "I stopped looking" wear the shape of "they agree". Built on **v2.3.7 "Overtone"** — the audio-provenance release: a per-register write attribution (*what wrote this, and from which instruction*) plus a per-CPU-cycle mix trace, and the discovery that Pixel Provenance had shipped non-functional for four releases because run-ahead's rollback cleared its store before any UI could read it — in three more places than the v2.3.6 fix had enumerated. Built on **v2.3.6 "Sounding"** — measuring, and what a measurement may claim. Two shipped features are found never to have worked: **Pixel Provenance** returned an empty report for every user on the default `run_ahead = 1` (its rollback is the last thing before the frontend takes the lock, so the panel always looked after the wipe) and "click any pixel" was never implemented — two comments and four doc claims asserted the opposite of their own code, which is why four releases passed unchecked; and **Duck Hunt could never score**, its Zapper probe exactly inverting the "see nothing, then a bright spot" protocol. Two new tools built to **decline rather than guess**: the **Latency Oracle** (measures the game's own input lag; recommends a run-ahead depth and never applies one) and the **RAM Atlas** (classifies all 2 KiB of work RAM, then *verifies* a candidate by perturbing it — `Untested` is a third state distinct from `Inert`, and liveness names its lens). **APU Workstream D is closed** on three measured rejections plus the fat-LTO mechanism explaining them. Tools and Debug are regrouped by task. Core gains one `const fn` getter, so AccuracyCoin 141/141 is verified, not asserted. Built on **v2.3.5 "Manifest"** — the declaration release: what the core says about itself. A user reported RetroArch still showing the pre-relicense MIT/Apache-2.0 terms, and it was: RetroArch reads `dist/info/` from **libretro/libretro-super**, a SEPARATE copy nothing synced, so the v2.2.9 GPL relicense never reached the file users see. Corrected to `GPLv3+` with a standing `libretro_info_audit.rs` that makes the upstream sync a **copy** rather than a re-derivation, and a licence change is now a mandatory upstream-sync trigger. Auditing the wrapper then found **five further defects, every one with correct emulation behind it** — PAL ran 20.2% fast, Reset did nothing ever, unload leaked Game Genie indices, the aspect ratio assumed square pixels, and the Zapper was unreachable — plus a **use-after-free** in the controller tables caught in review. The crate went from zero tests to eight. The APU also gained its first throughput bench and a default-configuration mix specialization (−3.3% to −4.2% on `nes_run_frame_nestest`), so **AccuracyCoin 141/141 was VERIFIED, not asserted**. Built on **v2.3.4 "Ledger"** — the coverage release: three boards (mapper 176 submapper 2 WAIXING-FS005, 154 NAMCOT-3453, 243 Sachen SA-020A, breadth **172 → 174 families**), the coverage harness moved onto the frontend's real load path, and the defect that exposed — the per-game database reading a `0` Mapper column as "force NROM" and overwriting correct headers, leaving **every Sachen cartridge** unloadable since **v1.2.0**. **This release touches the emulation core**, so AccuracyCoin exactly 141/141 is **verified, not asserted by construction**. Its Workstream C (the APU at 18.7% of frame time) was carried to v2.3.5 and delivered there. Built on **v2.3.3 "Cadence"** — the display-pacing release: the run-ahead throttle oscillation traced to a stale median (a gate counting 120 frames of a 600-sample ring), a predictive engage arm that converges a `run_ahead = 3` host in 2.8 s instead of 12.1 s, and the `wp_presentation` measurement apparatus that made the diagnosis possible. **No emulation-core changes** (AccuracyCoin exactly 141/141). Built on **v2.3.2 "Lucid"** (pixel provenance + deterministic replay attestation), **v2.3.1 "Plumb Line"** (ten measured rejections), and **v2.3.0 "Datum II"**, the capstone that **closed** the v2.2.6 → v2.3.0 line (true multi-viewport OS-window detach, the emulator-lock frame-pacing fix, a −5.1% byte-identical PPU optimization, and both forum-reported accuracy items verified already-correct) — all on the **v2.0.0 "Timebase"** MAJOR base (the one-clock / every-cycle-bus-access scheduler rewrite). **v1.0.0** was the first stable, production cut. As of **v2.2.9**, RustyNES is **GPL-3.0-or-later** — a derivative work of GPL-licensed emulators (ADR 0036); a licensing correction, **not** a SemVer break (no public-API or save-state change). `docs/STATUS.md` is the authoritative current-state record; `CHANGELOG.md` carries the full per-release history.
+**Current release: v2.5.1 "Retrace"** — the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned. Built on **v2.5.0 "Rungwork"** — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** — the core learns arithmetic. Built on **v2.4.5 "Compass"** — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** — the first real RTL. The 6502's eight-cycle reset and the seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. A mutation the test ROM was built to catch came back NOT CAUGHT because `TSX` leaves exactly the flags a wrongly-flagging `TXS` would compute, and a harness bug made every mutation report a catch including the baseline. The emulation core is untouched. Built on **v2.4.3 "Touchstone"** — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched. Built on **v2.4.2 "Cairn"** — the **rung-0 compare surface**. A cairn is a marker set along a route so you can tell you are still on it, which is what a rolling per-cycle hash checkpoint is. The constraint nobody budgets for in co-simulation is trace *volume*, not simulation time, and it is now **measured**: 3 frames of AccuracyCoin is 89,343 CPU cycles, **5,372,427 bytes** of `irq.csv` against **352 bytes** of `ckpt.bin` — a factor of **15,263** — so both sides chain a hash and compare every 4096 cycles, and only the divergent window is re-run with full capture. **What is hashed is a decision about hardware, not about convenience**: `CycleRecord` carries 29 fields and most are RustyNES's *model*, so `Observable` is the subset a device can genuinely produce, the IRQ pair is OR'd before hashing because hardware has one wire-OR'd /IRQ pin, and `pc` is marked DUT-observable rather than pin-observable. The emulation core is untouched. Built on **v2.4.1 "Fabric"** — RustyNES as the oracle a new implementation is verified against. It opens the **v2.4.1 → v2.5.0 "Fabric"** line: a new NES core in SystemVerilog, written from public hardware documentation in a sibling repository, with this emulator as its verification oracle. RustyNES is not being ported to FPGA and cannot be; `crates/rustynes-cosim` is the boundary, and the firewall extends to HDL (ADR 0037). **v2.5.0 is scoped to "the 6502 rung closes"**, not a finished core. Excluding the crate from the workspace is the load-bearing detail — cargo unifies features, and `irq-timing-trace` selects a *different* per-dot loop in `Bus::tick_one_cpu_cycle`, so the accuracy battery had been validating a scheduler no user runs. It also carries **v2.4.0 "Concordance"**, which merged to `main` and was never tagged: atomic durable writes on every path that persists user data, `Nes::timeline_generation()`, and the 15-anchor release audit. AccuracyCoin **141/141** and nestest 0-diff verified, not asserted. Previously, **v2.3.9 "Crucible"** — what the gates actually cover. A crucible tests to destruction rather than inspects, and this release does that to the project's own checks. **The docs-only CI skip had never worked**: `predicate-quantifier` defaults to `some`, so the `code` filter's leading `'**'` matched everything and all seven `!` exclusions under it were dead from the day they were written — fixed with **two** filter steps, because the quantifier is step-level and `accuracy` is a list of *alternatives* that becomes unsatisfiable under `every`, so the one-line fix would have silently disabled the accuracy battery while repairing a different gate. **`test-roms` now runs at review time**, path-filtered over the chip crates, the core, `rustynes-gamedb`, the harness and `tests/` (11 of the last 40 merged PRs, so ~72% still pay nothing). **A freeze from one cartridge kept writing into the next** — an active per-frame write into the wrong game — closed by a ROM-transition sweep across every panel under one rule: derived output discarded, user-authored input kept, and only input that actively *writes* neutralised. **The config file is written atomically and durably** (seven properties, five from review rather than the first draft). Plus 257 lines of dead code removed, 25 of 29 `#[allow(dead_code)]` attributes found to suppress nothing, `undocumented_unsafe_blocks` made a gate, and two `cargo deny` ignores retired on their own stated condition. `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 and nestest 0-diff are verified, not asserted**. Built on **v2.3.8 "Parallax"** — which pixels differ, not just which frame: `Probe` could say two configurations of the same ROM diverge and *at which frame* and nothing about where or why, because a trial reduces each frame to one `u64`. The **Divergence Lens** keeps the full output instead of its hash and reports the *shape* of the difference (population count, first pixel in raster order, inclusive bounding box), localises on the **index** framebuffer so a palette difference cannot masquerade as a rendering one, hands the located pixel to Pixel Provenance so the answer is a cause rather than a coordinate, and answers `Inconclusive` rather than letting "I stopped looking" wear the shape of "they agree". Built on **v2.3.7 "Overtone"** — the audio-provenance release: a per-register write attribution (*what wrote this, and from which instruction*) plus a per-CPU-cycle mix trace, and the discovery that Pixel Provenance had shipped non-functional for four releases because run-ahead's rollback cleared its store before any UI could read it — in three more places than the v2.3.6 fix had enumerated. Built on **v2.3.6 "Sounding"** — measuring, and what a measurement may claim. Two shipped features are found never to have worked: **Pixel Provenance** returned an empty report for every user on the default `run_ahead = 1` (its rollback is the last thing before the frontend takes the lock, so the panel always looked after the wipe) and "click any pixel" was never implemented — two comments and four doc claims asserted the opposite of their own code, which is why four releases passed unchecked; and **Duck Hunt could never score**, its Zapper probe exactly inverting the "see nothing, then a bright spot" protocol. Two new tools built to **decline rather than guess**: the **Latency Oracle** (measures the game's own input lag; recommends a run-ahead depth and never applies one) and the **RAM Atlas** (classifies all 2 KiB of work RAM, then *verifies* a candidate by perturbing it — `Untested` is a third state distinct from `Inert`, and liveness names its lens). **APU Workstream D is closed** on three measured rejections plus the fat-LTO mechanism explaining them. Tools and Debug are regrouped by task. Core gains one `const fn` getter, so AccuracyCoin 141/141 is verified, not asserted. Built on **v2.3.5 "Manifest"** — the declaration release: what the core says about itself. A user reported RetroArch still showing the pre-relicense MIT/Apache-2.0 terms, and it was: RetroArch reads `dist/info/` from **libretro/libretro-super**, a SEPARATE copy nothing synced, so the v2.2.9 GPL relicense never reached the file users see. Corrected to `GPLv3+` with a standing `libretro_info_audit.rs` that makes the upstream sync a **copy** rather than a re-derivation, and a licence change is now a mandatory upstream-sync trigger. Auditing the wrapper then found **five further defects, every one with correct emulation behind it** — PAL ran 20.2% fast, Reset did nothing ever, unload leaked Game Genie indices, the aspect ratio assumed square pixels, and the Zapper was unreachable — plus a **use-after-free** in the controller tables caught in review. The crate went from zero tests to eight. The APU also gained its first throughput bench and a default-configuration mix specialization (−3.3% to −4.2% on `nes_run_frame_nestest`), so **AccuracyCoin 141/141 was VERIFIED, not asserted**. Built on **v2.3.4 "Ledger"** — the coverage release: three boards (mapper 176 submapper 2 WAIXING-FS005, 154 NAMCOT-3453, 243 Sachen SA-020A, breadth **172 → 174 families**), the coverage harness moved onto the frontend's real load path, and the defect that exposed — the per-game database reading a `0` Mapper column as "force NROM" and overwriting correct headers, leaving **every Sachen cartridge** unloadable since **v1.2.0**. **This release touches the emulation core**, so AccuracyCoin exactly 141/141 is **verified, not asserted by construction**. Its Workstream C (the APU at 18.7% of frame time) was carried to v2.3.5 and delivered there. Built on **v2.3.3 "Cadence"** — the display-pacing release: the run-ahead throttle oscillation traced to a stale median (a gate counting 120 frames of a 600-sample ring), a predictive engage arm that converges a `run_ahead = 3` host in 2.8 s instead of 12.1 s, and the `wp_presentation` measurement apparatus that made the diagnosis possible. **No emulation-core changes** (AccuracyCoin exactly 141/141). Built on **v2.3.2 "Lucid"** (pixel provenance + deterministic replay attestation), **v2.3.1 "Plumb Line"** (ten measured rejections), and **v2.3.0 "Datum II"**, the capstone that **closed** the v2.2.6 → v2.3.0 line (true multi-viewport OS-window detach, the emulator-lock frame-pacing fix, a −5.1% byte-identical PPU optimization, and both forum-reported accuracy items verified already-correct) — all on the **v2.0.0 "Timebase"** MAJOR base (the one-clock / every-cycle-bus-access scheduler rewrite). **v1.0.0** was the first stable, production cut. As of **v2.2.9**, RustyNES is **GPL-3.0-or-later** — a derivative work of GPL-licensed emulators (ADR 0036); a licensing correction, **not** a SemVer break (no public-API or save-state change). `docs/STATUS.md` is the authoritative current-state record; `CHANGELOG.md` carries the full per-release history.
RustyNES follows [Semantic Versioning 2.0.0](https://semver.org/).
@@ -96,7 +96,8 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide
| **v2.4.7 "Keystone"** | The stack closes, and a dead line proves itself dead. The 6502's stack group, `JSR`/`RTS`/`RTI`, and `JMP` in both forms (`RustyNES_MiSTer@3560c98`). Four ROMs, **752 records**, matching the oracle on all seven CPU fields, with seven mutations demonstrated to break it. **The oracle found a real RTL bug before any mutation did**: `JMP ($C090)` reached `$77A0`, because cycle 3 latched the fetched vector low byte into `adl` and destroyed the pointer low byte -- the divergence named the instruction and the wrong value said which byte had been read. **`JMP ($xxFF)` reproduces the hardware page-boundary bug**, and the ROM places a DISTINCT sentinel at `$C200` so a "corrected" 16-bit increment lands somewhere visibly wrong rather than somewhere plausible. **Verilator rejected a 33rd value in a five-bit `op_e`** -- the good outcome, since a wrapped enum decodes one opcode as another with no diagnostic anywhere. **A mutation came back NOT CAUGHT because the arm it targeted was dead code**: `AM_JSR` never reads `store_val`, it drives `dout` directly, so the mutation was evidence about the code rather than about the test. Also fixed: `cpu-gate` hardcoded `--cycles 301` for every ROM, so the documented accuracy command could not gate the two most recent ones (it failed closed, so no false pass was possible). The emulation core is untouched. |
| **v2.4.8 "Palimpsest"** | Read-modify-write, and a gate that cannot see its own subject. `ASL`/`LSR`/`ROL`/`ROR` and `INC`/`DEC` across the accumulator form and four memory modes -- 28 opcodes (`RustyNES_MiSTer@0cb628f`). Five ROMs, **1110 records**, matching the oracle on all seven CPU fields, with eight mutations demonstrated to break it. **The release is named for something rung 1 cannot verify**: hardware writes the UNMODIFIED byte back before the modified one, and skipping that write changes no register, flag, final memory content or cycle count -- two mutations come back NOT CAUGHT, and that is recorded rather than glossed. It matters on hardware, where the middle write reaches mapper registers and I/O. **The mutation harness had been measuring against its own mutants**: it captured its pristine copy at source time and restored at the START of each run, so re-sourcing promoted the last mutant to baseline -- two were silently live while later results were measured against them. `tb/mutate.sh` captures once into a file it refuses to overwrite, requires the baseline to PASS first, and reports three outcomes. The emulation core is untouched. |
| **v2.4.9 "Plumbline II"** | The bus half of rung 2, and what it found the day it existed. `make -C tb cpu-bus-gate` compares per-cycle `bus_addr`, `bus_data` and `bus_access` against the oracle (`RustyNES_MiSTer@715952b`) -- and **both mutations v2.4.8 recorded as NOT CAUGHT are caught by it**, so the release named for the double write can finally verify one. It found **two real defects on its first run**, neither visible to rung 1: indexed RMW skipped its dummy read, and `STA $xxxx,X` without a page cross **wrote twice** -- with a comment directly above it stating the correct behaviour while the code did the opposite. Divergences went 7 -> 1 -> 0. Also lands the logical group (`AND`/`ORA`/`EOR`/`BIT`, a hard prerequisite that was simply missing) and the undocumented opcodes (`LAX`, `SAX`, the six RMW combinations, the multi-byte `NOP`s), taking rung 1 to **seven ROMs, 1663 records**. **Both sides must start from the same work RAM**: the oracle seeds its 2 KiB from a PRNG, so `.ram_init.bin` is exported and loaded rather than the PRNG being reimplemented in C++ where a second copy would drift. `pc` is populated but **not compared** -- the two sides mean different things by it and agree on only 45% of cycles, so it labels divergences instead. Three tests agreed with their own mutations. The emulation core is untouched. |
-| **v2.5.0 "Rungwork"** (current) | The 6502 rung, and the two gates it cannot reach. Interrupts in the DUT -- `nmi_n`/`irq_n`, the /NMI **edge latch** and level-sampled /IRQ, `BRK`, the vectors, `RTI`, the **NMI/BRK hijack** decided at the push, and **delayed-I** falling out of where the poll sits (`RustyNES_MiSTer@27171cd`). The **indirect modes** close the last documented addressing gap. **`pc` now agrees on 100% of cycles** (3551/3551) and is a compared field, after the wrapper was given the oracle's own definition -- two corrections were needed, and the first left a uniform one-instruction lag, which is the tell that a sampling PHASE is wrong rather than the arithmetic. **nestest matches for 27,388 cycles** and 8571 instructions. Five defects found by the bus gate, including `RTS` reading the incremented address and `build()` stamping over every ROM's interrupt vectors -- where **both sides would have agreed on the same wrong ROM**. **Two of this release's own stated gates are structurally blocked and neither is a defect**: nestest 0-diff and the 5 M-cycle window need a PPU (rung 3), and the interrupt-injection sweep has no oracle-side stimulus, so the pins, hijack and delayed-I are implemented and NOT oracle-verified. [ADR 0038](docs/adr/0038-cosim-interrupt-injection-api.md) records the decision to allow a default-off injection API and the two preconditions that void it. **No upstream libretro sync**: the cadence now waits for the MiSTer core to be complete. The emulation core is untouched. |
+| **v2.5.0 "Rungwork"** | The 6502 rung, and the two gates it cannot reach. Interrupts in the DUT -- `nmi_n`/`irq_n`, the /NMI **edge latch** and level-sampled /IRQ, `BRK`, the vectors, `RTI`, the **NMI/BRK hijack** decided at the push, and **delayed-I** falling out of where the poll sits (`RustyNES_MiSTer@27171cd`). The **indirect modes** close the last documented addressing gap. **`pc` now agrees on 100% of cycles** (3551/3551) and is a compared field, after the wrapper was given the oracle's own definition -- two corrections were needed, and the first left a uniform one-instruction lag, which is the tell that a sampling PHASE is wrong rather than the arithmetic. **nestest matches for 27,388 cycles** and 8571 instructions. Five defects found by the bus gate, including `RTS` reading the incremented address and `build()` stamping over every ROM's interrupt vectors -- where **both sides would have agreed on the same wrong ROM**. **Two of this release's own stated gates are structurally blocked and neither is a defect**: nestest 0-diff and the 5 M-cycle window need a PPU (rung 3), and the interrupt-injection sweep has no oracle-side stimulus, so the pins, hijack and delayed-I are implemented and NOT oracle-verified. [ADR 0038](docs/adr/0038-cosim-interrupt-injection-api.md) records the decision to allow a default-off injection API and the two preconditions that void it. **No upstream libretro sync**: the cadence now waits for the MiSTer core to be complete. The emulation core is untouched. |
+| **v2.5.1 "Retrace"** (current) | A return address, and a gate that reported a pass it could not have earned. **Rung 2 closes**: `tb/interrupt_sweep.py` asserts /NMI, /IRQ or **both together** before instruction K and holds it, for every K -- **60 injection points, 0 divergences** (`RustyNES_MiSTer@9425d73`). It found that **a hardware interrupt pushed a return address one byte too high**, because `AM_BRK` fell through a generic operand-fetch increment shared with every other addressing mode; `BRK` and an interrupt share that mode and *disagree* about it, so for `BRK` two writers assigned the same value and the fault was invisible -- **`BRK` passing 186/186 is what kept it hidden**. Before that, the injection was **wired to a dead path** (`Bus::poll_nmi`, which the production CPU does not use), taking IRQ 0/8 to 4/8 and NMI 0/8 to 5/8 when moved. **[ADR 0038](docs/adr/0038-cosim-interrupt-injection-api.md)'s own gate 2a reported a false pass**: it piped a `cargo-expand` that is not installed into a counting `grep`, with stderr discarded, so it counted an empty stream and printed the **0 it was looking for** -- caught by the control, not by reading, since the same command with the feature ENABLED also returned 0 (now measured: off = 0, on = 17). The sweep gained its both-pins case because a **priority-inversion mutation came back NOT CAUGHT**: with one pin at a time, an inverted priority is indistinguishable from a correct one. **A published finding is retracted** -- a "five-cycle interrupt sequence" that was seven throughout, from differencing two cycle numbers without checking which instruction each belonged to. nestest 0-diff and the 5 M-cycle window are **reclassified as rung-3 criteria**, not carried as debt. Also lands the **v2.5.1 → v2.7.0 programme**. `rustynes-core` changes, so AccuracyCoin **141/141 verified**. |
> **Forward path.** The v2.0.x "Harbor", v2.1.x "Fathom", and v2.2.x lines have all shipped; the v2.2.6 → v2.3.0 line has now **closed** with v2.3.0 "Datum II"; the v2.3.x performance campaign has now **shipped in full**, as three releases: **v2.3.1 "Plumb Line"** absorbed both the measurement apparatus and the core hot-path campaign, whose ten items were all measured and all rejected and so had no shippable content of their own; **v2.3.2 "Lucid"** the novel features (pixel provenance + replay attestation); and **v2.3.3 "Cadence"** the display-pacing work — the run-ahead throttle oscillation traced to a stale median, the predictive engage arm, and the `wp_presentation` measurement apparatus that made the diagnosis possible. The campaign closed there; **v2.3.4 "Ledger"** opened the next line with mapper coverage — three boards to **174 families**, and the coverage harness moved onto the frontend's real load path, which exposed a per-game-database defect that had left every Sachen cartridge unloadable since v1.2.0. Its Workstream C, the APU at 18.7% of frame time, was not delivered there and landed in **v2.3.5 "Manifest"**, which is otherwise about what the core declares about itself: the libretro `.info` licence drift a user reported, and the five wrapper defects auditing it uncovered. The line then continued as a **measurement-and-honesty** run rather than a feature one: **v2.3.6 "Sounding"** (two shipped features found never to have worked; the Latency Oracle and RAM Atlas both built to decline rather than guess), **v2.3.7 "Overtone"** (audio provenance, and the same-timeline-restore defect found in three more places than the v2.3.6 fix had enumerated), **v2.3.8 "Parallax"** (the Divergence Lens — which pixels differ, not just which frame), and **v2.3.9 "Crucible"** — which turned the same scrutiny on the project's own gates and found a docs-only CI skip that had never worked, an accuracy battery that only ran after merge, and a freeze from one cartridge writing into the next. Note the codenames diverged from this plan as written: what shipped as v2.3.2 took "Lucid" rather than the planned "Grain"/"Conduit II", and v2.3.3 is "Cadence". RustyNES is **permanently open-source and income-free** (ADR 0035): the earlier "joint Google Play + App Store + AltStore + F-Droid launch" is **withdrawn** — any store listing is a **free** app with **no monetization** (no ads, tracking, or paid unlock), an unversioned later step. `to-dos/ROADMAP.md` is the authoritative forward roadmap.
diff --git a/crates/rustynes-core/Cargo.toml b/crates/rustynes-core/Cargo.toml
index 2d1f62af..3153eca0 100644
--- a/crates/rustynes-core/Cargo.toml
+++ b/crates/rustynes-core/Cargo.toml
@@ -47,6 +47,24 @@ ppu-idle-line-fast = ["rustynes-ppu/ppu-idle-line-fast"]
cpu-boot-trace = ["std"]
# v2.0 R1c-1 diagnostic — per-instruction (PC, cpu_cycle) trace. Forward to rustynes-cpu.
cpu-instr-cycle-trace = ["rustynes-cpu/cpu-instr-cycle-trace"]
+# v2.5.1 (ADR 0038) — a TEST-ONLY interrupt-injection API for rung 2's sweep.
+#
+# Rung 2 needs NMI and IRQ asserted at chosen cycles on BOTH sides of the
+# co-simulation, and the oracle had no way to receive that stimulus: its /IRQ
+# comes from the APU frame counter or a mapper and its /NMI from the PPU, none
+# of which exist at the CPU rung.
+#
+# ADR 0038 admits this under six constraints. Two are preconditions of merging
+# and are measured, not assumed: ZERO hot-path cost when off, and byte-identical
+# default output verified with the feature ABSENT *and* PRESENT-BUT-UNUSED. The
+# second case is distinct because `irq-timing-trace` above selects a DIFFERENT
+# per-dot loop merely by being compiled in -- the exact failure this guards.
+#
+# Consumed ONLY by the excluded `rustynes-cosim` crate. Nothing in the workspace
+# enables it; `cosim_manifest_audit.rs` asserts that crate stays excluded, and
+# that assertion is load-bearing here, because cargo unifies features across a
+# workspace build.
+cosim-interrupt-inject = []
# v1.1.0 beta.2 (Workstream C) — debugger devtools hooks: PC/exec breakpoints (and,
# later, the trace logger + event viewer) checked in the `run_frame` loop. Off by
# default so the headless test/bench builds keep a byte-identical hot path + the
diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs
index fd019ac2..5e13cd42 100644
--- a/crates/rustynes-core/src/bus.rs
+++ b/crates/rustynes-core/src/bus.rs
@@ -521,6 +521,23 @@ pub struct LockstepBus {
last_nmi_level: bool,
/// Latched NMI edge (consumed by `poll_nmi`).
nmi_edge_latch: bool,
+ /// v2.5.1 (ADR 0038) — externally asserted /NMI, for co-simulation only.
+ ///
+ /// Active-high here (`true` = the pin is asserted, i.e. /NMI low). It is
+ /// OR'd into the poll rather than replacing it, so an injected NMI and a
+ /// PPU-generated one are the same event to the CPU -- which is the point:
+ /// the API sets the pin the CPU samples and does nothing else. It does not
+ /// bypass the poll, force a vector, or short-circuit the sequence.
+ ///
+ /// The field does not exist in a default build.
+ #[cfg(feature = "cosim-interrupt-inject")]
+ inject_nmi: bool,
+ /// v2.5.1 (ADR 0038) — externally asserted /IRQ. Level-sensitive, exactly
+ /// as the pin is, so it is masked by `I` through the CPU's own logic and a
+ /// pulse shorter than a poll is missed. Modelling it as a latch would make
+ /// injected IRQs behave unlike real ones.
+ #[cfg(feature = "cosim-interrupt-inject")]
+ inject_irq: bool,
/// v2.0 master-clock R1 substrate (Phase 1): PPU progress in master-clock
/// units, consumed by `run_ppu_to(target)` (ticks a dot while
@@ -888,6 +905,10 @@ impl LockstepBus {
dma_total: 0,
last_nmi_level: false,
nmi_edge_latch: false,
+ #[cfg(feature = "cosim-interrupt-inject")]
+ inject_nmi: false,
+ #[cfg(feature = "cosim-interrupt-inject")]
+ inject_irq: false,
ppu_clock: 0,
cpu_div_cached,
ppu_div_cached,
@@ -1188,6 +1209,18 @@ impl LockstepBus {
self.open_bus = next();
}
+ /// Assert or release the injected /NMI pin. See [`crate::Nes::inject_nmi`].
+ #[cfg(feature = "cosim-interrupt-inject")]
+ pub const fn set_inject_nmi(&mut self, asserted: bool) {
+ self.inject_nmi = asserted;
+ }
+
+ /// Assert or release the injected /IRQ pin. See [`crate::Nes::inject_irq`].
+ #[cfg(feature = "cosim-interrupt-inject")]
+ pub const fn set_inject_irq(&mut self, asserted: bool) {
+ self.inject_irq = asserted;
+ }
+
/// Borrow the framebuffer (RGBA8, 256x240).
#[must_use]
pub fn framebuffer(&self) -> &[u8] {
@@ -4563,6 +4596,16 @@ impl Bus for LockstepBus {
}
fn irq_level(&self) -> bool {
+ // Bound BEFORE the expression rather than as an inline `#[cfg]` block
+ // inside it. The two forms compile identically -- the default build
+ // still emits nothing named `inject_`, which is ADR 0038's structural
+ // gate -- but a `cfg` block in the middle of a boolean chain is hard to
+ // read, and this chain is the wire-OR of every /IRQ source.
+ #[cfg(feature = "cosim-interrupt-inject")]
+ let injected = self.inject_irq;
+ #[cfg(not(feature = "cosim-interrupt-inject"))]
+ let injected = false;
+
// v2.8.0 Phase 4 — boards without an IRQ source have the default
// `irq_pending() == false`; skip the per-cycle virtual call.
// v2.0.0 beta.5 — `vs_external_irq` is the DualSystem partner
@@ -4571,9 +4614,31 @@ impl Bus for LockstepBus {
(self.mapper_caps.irq_source && self.mapper.irq_pending())
|| self.apu.irq_line()
|| self.vs_external_irq
+ // v2.5.1 (ADR 0038). Level-sensitive and OR'd, exactly like
+ // `vs_external_irq` beside it -- which is the precedent: an external
+ // IRQ source already joins the wire-OR here, and this is the same
+ // shape with a different driver.
+ || injected
}
fn nmi_level(&self) -> bool {
+ // v2.5.1 (ADR 0038). Injected here and NOT in `poll_nmi`, because
+ // `poll_nmi` is not the path the production CPU uses: it samples this
+ // LEVEL every cycle and edge-detects it itself (`nmi_first_tick` ->
+ // `pending_nmi` -> `armed_nmi`).
+ //
+ // The first implementation injected at `poll_nmi`, which looks like the
+ // right function and is dead for this path. The rung-2 sweep found it on
+ // its first real run -- the DUT took the injected NMI and the oracle did
+ // not -- which is exactly the defect class a co-simulation exists to
+ // catch, arriving in the harness rather than in the RTL.
+ //
+ // A LEVEL, not a latch: the CPU does its own edge detection, so
+ // consuming it here would make an injected NMI behave unlike a PPU one.
+ #[cfg(feature = "cosim-interrupt-inject")]
+ if self.inject_nmi {
+ return true;
+ }
self.ppu.nmi_line()
}
diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs
index fff7935a..43b9cf49 100644
--- a/crates/rustynes-core/src/nes.rs
+++ b/crates/rustynes-core/src/nes.rs
@@ -1251,6 +1251,39 @@ impl Nes {
&mut self.bus
}
+ /// Assert or release the external /NMI pin. **Co-simulation only.**
+ ///
+ /// v2.5.1, [ADR 0038]. Rung 2's interrupt sweep needs the same stimulus on
+ /// both sides, and this emulator had no way to receive it: its /NMI comes
+ /// from the PPU and its /IRQ from the APU frame counter or a mapper, none of
+ /// which exist at the CPU rung of the co-simulation.
+ ///
+ /// `true` asserts the pin. It is OR'd into the CPU's existing poll and
+ /// consumed on the edge exactly as a PPU-generated NMI is, so the CPU cannot
+ /// tell the two apart -- which is the property that makes the sweep test the
+ /// CPU rather than the injection. It does **not** bypass the poll, force a
+ /// vector, or short-circuit the sequence.
+ ///
+ /// Gated behind `cosim-interrupt-inject`, which nothing in the workspace
+ /// enables. A default build does not contain this method or its state.
+ ///
+ /// [ADR 0038]: https://github.com/doublegate/RustyNES/blob/main/docs/adr/0038-cosim-interrupt-injection-api.md
+ #[cfg(feature = "cosim-interrupt-inject")]
+ pub const fn inject_nmi(&mut self, asserted: bool) {
+ self.bus.set_inject_nmi(asserted);
+ }
+
+ /// Assert or release the external /IRQ pin. **Co-simulation only.**
+ ///
+ /// Level-sensitive, as the pin is: it is masked by `I` through the CPU's own
+ /// logic, and a pulse shorter than a poll is missed. Holding it asserted
+ /// across several cycles is how a real device drives it. See
+ /// [`Self::inject_nmi`] for the contract and the ADR.
+ #[cfg(feature = "cosim-interrupt-inject")]
+ pub const fn inject_irq(&mut self, asserted: bool) {
+ self.bus.set_inject_irq(asserted);
+ }
+
/// Borrow the CPU (debugger / tests).
#[must_use]
pub const fn cpu(&self) -> &Cpu {
diff --git a/crates/rustynes-cosim/Cargo.lock b/crates/rustynes-cosim/Cargo.lock
index 2f18a035..09c207e8 100644
--- a/crates/rustynes-cosim/Cargo.lock
+++ b/crates/rustynes-cosim/Cargo.lock
@@ -98,7 +98,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags",
"libm",
@@ -107,7 +107,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags",
"lz4_flex",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "rustynes-cosim"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"rustynes-core",
"sha2",
@@ -129,7 +129,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags",
"thiserror",
@@ -137,7 +137,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags",
"rustynes-apu",
@@ -146,7 +146,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "2.5.0"
+version = "2.5.1"
dependencies = [
"bitflags",
"libm",
diff --git a/crates/rustynes-cosim/Cargo.toml b/crates/rustynes-cosim/Cargo.toml
index bddb4469..697f9492 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 = "2.5.0"
+version = "2.5.1"
edition = "2024"
rust-version = "1.96"
license = "GPL-3.0-or-later"
@@ -51,6 +51,10 @@ rustynes-core = { path = "../rustynes-core", features = [
# instruction at a divergence. Enabled only after checking empirically
# that it changes NOTHING else: see the comparison in the commit body.
"cpu-instr-cycle-trace",
+ # v2.5.1 (ADR 0038). Rung 2's sweep needs the same interrupt stimulus on both
+ # sides; nothing else in the workspace enables this, and `cosim_manifest_audit`
+ # asserts this crate stays excluded so cargo cannot unify it into the battery.
+ "cosim-interrupt-inject",
] }
sha2 = { version = "0.11", default-features = false }
diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs
index 40169691..c15c469c 100644
--- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs
+++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs
@@ -48,6 +48,13 @@ struct Args {
boot_trace: Option<(u64, u64)>,
irq_trace: Option,
checkpoint_interval: u64,
+ /// v2.5.1 — rung 2's interrupt sweep. Instruction-indexed, not
+ /// cycle-indexed: this side cannot assert a pin mid-instruction, so a
+ /// cycle-indexed sweep would not be comparable. See `Oracle::run_with_injection`.
+ inject_instructions: u64,
+ inject_nmi_at: Option,
+ inject_irq_at: Option,
+ inject_hold: u64,
}
fn usage() -> ! {
@@ -65,8 +72,14 @@ fn parse_args() -> Args {
let (mut seed, mut frames) = (0u64, 60u32);
let (mut boot_trace, mut irq_trace) = (None, None);
let mut checkpoint_interval = rustynes_cosim::checkpoint::DEFAULT_INTERVAL;
+ let (mut inject_instructions, mut inject_hold) = (0u64, 1u64);
+ let (mut inject_nmi_at, mut inject_irq_at) = (None, None);
let mut i = 0;
+ // Every value-taking arm below advances `i` by TWO, not one: this loop has
+ // no trailing increment, so stepping by one leaves `i` on the value, which
+ // then falls through to `_ => usage()` and prints the help text as though
+ // the FLAG were unknown. Stated once here rather than four times inline.
while i < argv.len() {
let need = |i: usize| -> &String { argv.get(i + 1).unwrap_or_else(|| usage()) };
match argv[i].as_str() {
@@ -95,6 +108,22 @@ fn parse_args() -> Args {
));
i += 2;
}
+ "--inject-instructions" => {
+ inject_instructions = need(i).parse().unwrap_or_else(|_| usage());
+ i += 2;
+ }
+ "--inject-nmi-instr" => {
+ inject_nmi_at = Some(need(i).parse().unwrap_or_else(|_| usage()));
+ i += 2;
+ }
+ "--inject-irq-instr" => {
+ inject_irq_at = Some(need(i).parse().unwrap_or_else(|_| usage()));
+ i += 2;
+ }
+ "--inject-hold" => {
+ inject_hold = need(i).parse().unwrap_or_else(|_| usage());
+ i += 2;
+ }
"--irq-trace" => {
irq_trace = Some(need(i).parse().unwrap_or_else(|_| usage()));
i += 2;
@@ -118,6 +147,10 @@ fn parse_args() -> Args {
boot_trace,
irq_trace,
checkpoint_interval,
+ inject_instructions,
+ inject_nmi_at,
+ inject_irq_at,
+ inject_hold,
}
}
@@ -208,6 +241,135 @@ fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, us
}
}
+/// Refuse an injection request that would silently produce a NON-INJECTED golden.
+///
+/// Every combination rejected here runs to completion and emits a plausible
+/// artifact with no pin ever asserted -- and a sweep comparing two non-injected
+/// runs agrees, reporting a pass for a stimulus that was never applied. That is
+/// this programme's recurring failure mode, so it is refused at the boundary
+/// rather than diagnosed later.
+///
+/// Returns the reason rather than exiting, so the rules are testable without a
+/// process boundary.
+fn injection_error(args: &Args) -> Option {
+ let pinned = args.inject_nmi_at.is_some() || args.inject_irq_at.is_some();
+ if pinned && args.inject_instructions == 0 {
+ return Some(
+ "--inject-{nmi,irq}-instr requires a non-zero --inject-instructions; without it \
+ the run falls back to a frame advance and no pin is ever asserted"
+ .to_owned(),
+ );
+ }
+ if args.inject_instructions == 0 {
+ return None;
+ }
+ if !pinned {
+ return Some(
+ "--inject-instructions was given with neither --inject-nmi-instr nor \
+ --inject-irq-instr; the run would assert no pin at all"
+ .to_owned(),
+ );
+ }
+ if args.inject_hold == 0 {
+ return Some("--inject-hold must be non-zero; a zero hold never asserts a pin".to_owned());
+ }
+ for (name, at) in [("nmi", args.inject_nmi_at), ("irq", args.inject_irq_at)] {
+ if let Some(k) = at
+ && k >= args.inject_instructions
+ {
+ return Some(format!(
+ "--inject-{name}-instr {k} is outside the {} instruction(s) this run executes; \
+ the pin would never be asserted",
+ args.inject_instructions
+ ));
+ }
+ }
+ None
+}
+
+fn validate_injection(args: &Args) {
+ if let Some(why) = injection_error(args) {
+ eprintln!("{why}");
+ usage();
+ }
+}
+
+/// The manifest lines describing WHICH KIND OF RUN produced these goldens.
+///
+/// An injection run is not a frame run, and the manifest must not describe it as
+/// one: `calls` counts executed INSTRUCTIONS there, while the shared field is
+/// named `run_frame_calls`, and the pin positions and hold that produced the
+/// stimulus were recorded nowhere at all. A DUT could not reproduce or audit the
+/// golden from the artifact -- which is the manifest's entire job.
+/// How much work this run performs, in the unit that run actually uses.
+///
+/// An injection run completes no frames, so describing it in frames is not a
+/// rounding error -- it is the wrong unit, and it is what made the frame-count
+/// warning fire on every correct sweep export.
+/// Warn when a run did not do what was asked -- in the unit that run uses.
+///
+/// Both arms matter, and the second was briefly LOST. Gating the frame warning
+/// on the run mode fixed a warning that fired on every correct injection export,
+/// and in doing so removed the jam signal from injection runs entirely, because
+/// that warning was the only thing checking them. A jammed CPU would then have
+/// produced a short golden in silence -- the exact failure the frame warning
+/// exists to prevent, moved rather than fixed.
+fn warn_if_incomplete(args: &Args, o: &Oracle, calls: u64, frames_actual: u64) {
+ if args.inject_instructions > 0 {
+ if calls != args.inject_instructions {
+ eprintln!(
+ " WARNING: requested {} instructions, executed {calls} (CPU jammed: {})",
+ args.inject_instructions,
+ o.nes().is_jammed()
+ );
+ }
+ } else if frames_actual != u64::from(args.frames) {
+ // Reachable when the CPU jams. Emit the goldens anyway -- a jammed ROM
+ // is a legitimate thing to compare a DUT against -- but never let the
+ // manifest claim a frame count that was not simulated.
+ eprintln!(
+ " WARNING: requested {} frames, simulated {frames_actual} (CPU jammed: {})",
+ args.frames,
+ o.nes().is_jammed()
+ );
+ }
+}
+
+fn run_scale(args: &Args) -> String {
+ if args.inject_instructions > 0 {
+ format!("{} instructions, injected", args.inject_instructions)
+ } else {
+ format!("{} frames", args.frames)
+ }
+}
+
+fn run_mode_block(args: &Args, calls: u64, frames_actual: u64) -> String {
+ if args.inject_instructions > 0 {
+ format!(
+ "run_mode = instruction-injection\n\
+ instr_req = {}\n\
+ instr_actual = {calls}\n\
+ inject_nmi_at= {}\n\
+ inject_irq_at= {}\n\
+ inject_hold = {}\n",
+ args.inject_instructions,
+ args.inject_nmi_at
+ .map_or_else(|| "none".to_owned(), |v| v.to_string()),
+ args.inject_irq_at
+ .map_or_else(|| "none".to_owned(), |v| v.to_string()),
+ args.inject_hold,
+ )
+ } else {
+ format!(
+ "run_mode = frames\n\
+ frames_req = {}\n\
+ frames_actual= {frames_actual}\n\
+ run_frame_calls = {calls}\n",
+ args.frames,
+ )
+ }
+}
+
fn main() {
let args = parse_args();
let rom =
@@ -249,22 +411,34 @@ fn main() {
// is swallowed by the frame_complete latch the reset sequence leaves set, so
// a bare loop emits an (N-1)-frame golden under a manifest claiming N.
let frame_before = o.nes().frame();
- let calls = o.advance_frames(u64::from(args.frames));
+ // The interrupt sweep runs INSTEAD of the frame advance: it steps a bounded
+ // number of instructions with a pin asserted for part of the run, which is
+ // the stimulus rung 2 compares. A frame advance would run past the window
+ // and bury the divergence under thousands of unrelated cycles.
+ validate_injection(&args);
+
+ let calls = if args.inject_instructions > 0 {
+ o.run_with_injection(
+ args.inject_instructions,
+ args.inject_nmi_at,
+ args.inject_irq_at,
+ args.inject_hold,
+ )
+ } else {
+ o.advance_frames(u64::from(args.frames))
+ };
let frames_actual = o.nes().frame() - frame_before;
let cycles = o.nes().cycle();
- if frames_actual != u64::from(args.frames) {
- // Reachable when the CPU jams. Emit the goldens anyway -- a jammed ROM
- // is a legitimate thing to compare a DUT against -- but never let the
- // manifest claim a frame count that was not simulated.
- eprintln!(
- " WARNING: requested {} frames, simulated {frames_actual} (CPU jammed: {})",
- args.frames,
- o.nes().is_jammed()
- );
- }
+ // The frame check applies to a FRAME run only. An injection run steps
+ // instructions and completes no frames at all, so this fired on every
+ // correct sweep export -- "requested 1 frames, simulated 0", with nothing
+ // wrong and the CPU not jammed. A warning that cries wolf on every valid run
+ // is how a real one comes to be ignored, and this one was invisible to me
+ // because the sweep script redirects stderr.
+ warn_if_incomplete(&args, &o, calls, frames_actual);
let base = args.out.join(&stem);
- println!("exporting goldens for {} ({} frames):", stem, args.frames);
+ println!("exporting goldens for {stem} ({}):", run_scale(&args));
let fb = o.nes().index_framebuffer();
assert_eq!(
@@ -306,13 +480,13 @@ fn main() {
(0, 0)
};
+ let mode_block = run_mode_block(&args, calls, frames_actual);
+
let manifest = format!(
"rom = {}\n\
rom_sha256 = {}\n\
seed = {}\n\
- frames_req = {}\n\
- frames_actual= {}\n\
- run_frame_calls = {}\n\
+ {}\
cpu_cycles = {}\n\
emulator = rustynes {}\n\
index_fb_len = {}\n\
@@ -323,9 +497,7 @@ fn main() {
args.rom.display(),
sha256_hex(&rom),
args.seed,
- args.frames,
- frames_actual,
- calls,
+ mode_block,
cycles,
env!("CARGO_PKG_VERSION"),
INDEX_FB_LEN,
diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs
index 4e5b1329..db2a2c9b 100644
--- a/crates/rustynes-cosim/src/lib.rs
+++ b/crates/rustynes-cosim/src/lib.rs
@@ -141,6 +141,79 @@ impl Oracle {
calls
}
+ /// Run `instructions` instructions, asserting an interrupt pin for part of
+ /// the run. **Co-simulation only**, behind `cosim-interrupt-inject`.
+ ///
+ /// # Why instruction boundaries rather than cycles
+ ///
+ /// The plan's rung-2 sweep asks for "NMI and IRQ at every master-clock
+ /// offset". That is **not reachable**, and the reason is structural rather
+ /// than an oversight: `Nes` exposes `run_frame()` and `step_instruction()`
+ /// and nothing finer, so this side cannot assert a pin mid-instruction. A
+ /// cycle-step API would be new hot-path core API, which ADR 0037's contract
+ /// forbids and ADR 0038 did not authorise.
+ ///
+ /// So the sweep injects **before instruction `at`** and holds for `hold`
+ /// instructions, on both sides — the DUT counts `o_sync` pulses to the same
+ /// index. That is deterministic, exactly comparable, and it does exercise
+ /// the hazards the sweep exists for: an interrupt arriving during a
+ /// branch-page-cross, an RMW, a `BRK`, or a `PLP`/`SEI`/`CLI` delayed-`I`
+ /// window is still an interrupt arriving *during* that instruction.
+ ///
+ /// **What it does not reach**, stated so it is not mistaken for coverage:
+ /// two different cycle offsets *within* one instruction are indistinguishable
+ /// here. Closing that needs the cycle-step API above, and it is deferred
+ /// rather than quietly skipped.
+ ///
+ /// No `cfg` here: this crate enables `rustynes-core/cosim-interrupt-inject`
+ /// mandatorily, exactly as it does the trace features, so a build of this
+ /// crate without it would produce an oracle that silently cannot inject --
+ /// the "absence of a signal read as a signal" failure the manifest comment
+ /// above is about.
+ pub fn run_with_injection(
+ &mut self,
+ instructions: u64,
+ nmi_at: Option,
+ irq_at: Option,
+ hold: u64,
+ ) -> u64 {
+ let mut executed = 0u64;
+ while executed < instructions {
+ // Assert on entry to the target instruction, release once `hold`
+ // instructions have run. Both edges land on a boundary the DUT can
+ // name by counting `o_sync`.
+ //
+ // `executed - k < hold` rather than `executed < k + hold`: the
+ // second form can overflow `u64` on a hostile `--inject-hold`,
+ // panicking in a debug build and WRAPPING in a release one -- which
+ // would silently release the pin instead of holding it, and a sweep
+ // would then report agreement about a stimulus that was never
+ // applied. The subtraction is guarded by the `>=` that precedes it.
+ let asserted = |k: u64| executed >= k && executed - k < hold;
+ if let Some(k) = nmi_at {
+ self.nes.inject_nmi(asserted(k));
+ }
+ if let Some(k) = irq_at {
+ self.nes.inject_irq(asserted(k));
+ }
+ self.nes.step_instruction();
+ executed += 1;
+ if self.nes.is_jammed() {
+ break;
+ }
+ }
+ // Leave both pins released, UNCONDITIONALLY -- not only the ones this
+ // call asserted. The guarded form protects exactly the case that cannot
+ // happen (this call left a pin high that it never touched) and skips the
+ // case that can: a pin left high by an EARLIER call, or by a bug in this
+ // one's loop. The `Oracle` outlives the run, so that state rides into
+ // the next comparison, where a stuck interrupt line looks like a core
+ // defect rather than like leaked harness state.
+ self.nes.inject_nmi(false);
+ self.nes.inject_irq(false);
+ executed
+ }
+
/// Arm the per-instruction boot trace over a cycle window.
pub fn enable_cpu_boot_trace(&mut self, capacity: usize, start: u64, end: u64) {
let cfg = CpuBootTraceConfig::cycles(start..=end);
diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info
index ddeb7146..0746796a 100644
--- a/crates/rustynes-libretro/rustynes_libretro.info
+++ b/crates/rustynes-libretro/rustynes_libretro.info
@@ -5,7 +5,7 @@ supported_extensions = "nes|fds"
corename = "RustyNES"
license = "GPLv3+"
permissions = ""
-display_version = "v2.5.0"
+display_version = "v2.5.1"
categories = "Emulator"
# Hardware Information
diff --git a/crates/rustynes-test-harness/tests/mister_source_map_audit.rs b/crates/rustynes-test-harness/tests/mister_source_map_audit.rs
new file mode 100644
index 00000000..b622ba32
--- /dev/null
+++ b/crates/rustynes-test-harness/tests/mister_source_map_audit.rs
@@ -0,0 +1,442 @@
+//! Pin the `MiSTer` hardware source map's citations to files that actually exist.
+//!
+//! `ref-docs/2026-08-23-fpga-nes-hardware-source-map.md` is the list of pages the
+//! FPGA core's RTL may be written from. Under the provenance firewall (ADR 0037)
+//! that list is not a convenience -- it is what makes "written from public
+//! documentation" a *checkable* claim rather than an assertion, since the
+//! reference cores are black boxes and there is no second source to fall back on.
+//!
+//! A citation that no longer resolves is therefore not a broken link. It is a
+//! behaviour with no permitted source, discovered at the moment someone is trying
+//! to implement it and is most inclined to go looking elsewhere. This test exists
+//! so the corpus cannot move out from under the map quietly.
+//!
+//! It found three defects on its first run: `APU_Envelope.xhtml`,
+//! `APU_Sweep.xhtml` and `MMC1_pinout.xhtml` were cited as bare filenames, in
+//! table cells that listed a second file after a fully-qualified first one.
+
+use std::path::{Path, PathBuf};
+
+fn workspace_root() -> PathBuf {
+ Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(Path::parent)
+ .expect("workspace root")
+ .to_path_buf()
+}
+
+/// Removes a temp directory on the way out, including during a panic.
+///
+/// A Drop guard rather than a trailing `remove_dir_all`: the trailing form does
+/// not run when an assert fires -- i.e. exactly when the test failed and the
+/// next run is most likely to trip over the leftovers.
+struct Cleanup(PathBuf);
+
+impl Drop for Cleanup {
+ fn drop(&mut self) {
+ // Ignored deliberately, and ONLY here: best-effort cleanup of a temp
+ // directory during unwinding, where there is nothing to report an error
+ // to. Every other error in this file is checked.
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+}
+
+const MAP: &str = "ref-docs/2026-08-23-fpga-nes-hardware-source-map.md";
+
+/// Extensions the corpus actually contains. `xhtml` is the load-bearing entry:
+/// the nesdev pages are all `.xhtml`, and an earlier hand-run of this check used
+/// a pattern without it, reporting "4 cited paths, 0 missing" against a file
+/// holding 32 citations.
+const EXTS: [&str; 4] = [".md", ".txt", ".html", ".xhtml"];
+
+/// Every backticked path-looking span in the document.
+///
+/// Deliberately extension-driven rather than "anything in backticks": the file
+/// also spans identifiers (`v`, `$2005`, `index_framebuffer`, `ppu-state-trace`)
+/// that are not paths and never will be. The extension list covers what the
+/// corpus actually contains -- and `xhtml` is the load-bearing entry, because the
+/// nesdev pages are all `.xhtml` and an earlier hand-run of this same check used
+/// a pattern without it. That run reported "4 cited paths, 0 missing" against a
+/// file holding 32 citations, and the reassuring number came from a pattern that
+/// could not match the extension every real citation uses.
+fn cited_paths(text: &str) -> Vec {
+ let mut out: Vec = spans(text)
+ .filter(|s| EXTS.iter().any(|e| s.ends_with(e)))
+ .map(str::to_owned)
+ .collect();
+ out.sort_unstable();
+ out.dedup();
+ out
+}
+
+/// Backticked spans that could be a path: the allowed character set and a dot.
+///
+/// Deliberately does NOT require a `/`. `cited_paths` is built on this, and a
+/// slash requirement there makes the bare-filename check below structurally
+/// incapable of firing -- the extractor would drop exactly the defect the check
+/// exists to catch, and the check would then pass over an empty list forever.
+///
+/// That is not hypothetical: it was introduced in this very file while
+/// refactoring for `unrecognised_extensions`, which DOES want a slash, and it
+/// survived a mutation pass because the bare-filename test fed a hand-built
+/// vector straight to the predicate instead of through the pipeline.
+fn spans(text: &str) -> impl Iterator- {
+ text.split('`').skip(1).step_by(2).filter(|s: &&str| {
+ !s.is_empty()
+ && s.contains('.')
+ && s.chars()
+ .all(|c| c.is_ascii_alphanumeric() || "._/-".contains(c))
+ })
+}
+
+/// Does this token end the way a filename ends?
+///
+/// The discriminator is the character class after the final dot: a filename's
+/// extension is letters (`board.pdf`, `notes.txt`), a version string's last
+/// component is digits (`v2.5.3`, `v2.4.9`). These documents are full of both,
+/// so the audit must separate them or it either misses real citations or fails
+/// on correct prose.
+fn looks_like_a_filename(s: &str) -> bool {
+ match s.rsplit_once('.') {
+ Some((stem, ext)) => {
+ !stem.is_empty()
+ && (1..=5).contains(&ext.len())
+ && ext.chars().all(|c| c.is_ascii_alphabetic())
+ }
+ None => false,
+ }
+}
+
+/// Path-like spans whose extension `cited_paths` does not recognise.
+///
+/// Without this the extractor FAILS OPEN. A new citation such as
+/// `ref-docs/board.pdf` is silently dropped, the existing 32 still clear the
+/// minimum-count check, and the audit reports success over a source nobody
+/// verified. That is the same shape as the `.xhtml` omission this file already
+/// records -- a pattern that cannot match looks exactly like content that is
+/// not there -- so the fix is to make the unmatched case LOUD rather than to
+/// widen the pattern and hope.
+fn unrecognised_extensions(text: &str) -> Vec<&str> {
+ spans(text)
+ // Path-like means EITHER a slash, OR a filename-shaped ending. The
+ // slash alone is not enough: `file.pdf` has no slash, so it fell
+ // through `cited_paths` (wrong extension) AND through here (no slash),
+ // vanishing from both -- the same blindness as the bare-filename case,
+ // one extension away.
+ //
+ // The ending test is what keeps a VERSION STRING out. `v2.5.3`'s final
+ // dot is followed by digits; a filename's is followed by letters.
+ .filter(|s| {
+ (s.contains('/') || looks_like_a_filename(s)) && !EXTS.iter().any(|e| s.ends_with(e))
+ })
+ .collect()
+}
+
+/// Citations with no directory component.
+///
+/// Extracted for the same reason as `classify`: with the document currently
+/// clean, deleting the check in place changes nothing and a mutation of it comes
+/// back NOT CAUGHT. A predicate a test can call directly is verifiable whether
+/// or not today's document happens to violate it.
+fn bare_filenames(cited: &[String]) -> Vec<&String> {
+ cited.iter().filter(|p| !p.contains('/')).collect()
+}
+
+/// The split between "verified to exist" and "tree absent, shape only".
+///
+/// Extracted so a test can drive it against a synthetic root. The case that
+/// matters is the one this machine cannot reproduce by inspection -- a checkout
+/// WITHOUT `nesdev_wiki/`, which is every CI runner -- and reasoning about it is
+/// what produced the bug in the first place.
+struct Classified<'a> {
+ checked: usize,
+ unavailable: Vec<&'a String>,
+ missing: Vec<&'a String>,
+}
+
+fn classify<'a>(root: &Path, cited: &'a [String]) -> Classified<'a> {
+ let mut out = Classified {
+ checked: 0,
+ unavailable: Vec::new(),
+ missing: Vec::new(),
+ };
+ for c in cited {
+ let top = c.split('/').next().unwrap_or("");
+ if root.join(top).is_dir() {
+ out.checked += 1;
+ // `is_file`, not `exists`: a citation is a document. `exists()`
+ // returns true for a directory, so a path that happened to match a
+ // directory name would report as a verified source when nothing
+ // readable is there.
+ if !root.join(c).is_file() {
+ out.missing.push(c);
+ }
+ } else {
+ out.unavailable.push(c);
+ }
+ }
+ out
+}
+
+#[test]
+fn every_cited_source_is_well_formed_and_resolves_where_it_can() {
+ let root = workspace_root();
+ let text =
+ std::fs::read_to_string(root.join(MAP)).unwrap_or_else(|e| panic!("read {MAP}: {e}"));
+
+ let cited = cited_paths(&text);
+
+ // Fail closed. Zero citations means the extraction stopped working, not that
+ // the map is clean -- the failure this repository keeps rediscovering.
+ assert!(
+ cited.len() >= 20,
+ "extracted only {} citations from {MAP}; the pattern is wrong, \
+ and a pattern that matches nothing looks exactly like a clean document",
+ cited.len()
+ );
+
+ // ---- Tier 0: the extractor saw everything path-shaped. ----
+ let unknown = unrecognised_extensions(&text);
+ assert!(
+ unknown.is_empty(),
+ "{MAP} contains {} path-like span(s) whose extension the extractor does \
+ not recognise: {unknown:#?}\n\
+ They are being SILENTLY DROPPED, so they are never existence-checked \
+ while the citation count still clears its minimum. Add the extension to \
+ EXTS, or stop citing it as a path.",
+ unknown.len()
+ );
+
+ // ---- Tier 1: shape. Always checked, corpus or no corpus. ----
+ //
+ // This is the tier that caught the real defects: three citations were bare
+ // filenames (`APU_Sweep.xhtml`) in table cells that listed a second file
+ // after a fully-qualified first one. A bare filename is ambiguous about
+ // which tree it lives in, which is the one thing a source map may not be.
+ let bare: Vec<&String> = bare_filenames(&cited);
+ assert!(
+ bare.is_empty(),
+ "{MAP} cites {} source(s) with no directory component: {bare:#?}\n\
+ A source map's citations must name the tree they live in.",
+ bare.len()
+ );
+
+ // ---- Tier 2: existence, for the trees this checkout actually has. ----
+ //
+ // `nesdev_wiki/` is GITIGNORED -- 3,407 files of upstream corpus that this
+ // repository deliberately does not vendor. So it is present on a developer's
+ // machine and absent in CI, and an unconditional existence check passes
+ // locally and fails every CI run. It did exactly that, on the release PR.
+ //
+ // The split is by whether the citation's top-level directory exists. Where
+ // it does, a missing file is a HARD failure -- that is the whole point of
+ // the audit. Where the tree is absent entirely, the citation is
+ // shape-checked only, and the count is REPORTED rather than passed over in
+ // silence: a check that quietly verifies less than it appears to is the
+ // exact failure this file exists to prevent.
+ let Classified {
+ checked,
+ unavailable,
+ missing,
+ } = classify(&root, &cited);
+
+ assert!(
+ missing.is_empty(),
+ "{MAP} cites {} source(s) that do not exist, in trees this checkout HAS: {missing:#?}\n\
+ Under ADR 0037 these are the ONLY permitted sources for that behaviour, so a \
+ dangling citation is a behaviour with no source -- fix the path or add a dated \
+ supplemental file.",
+ missing.len()
+ );
+
+ println!(
+ "source map: {} citations, all well-formed; {checked} verified to exist.",
+ cited.len()
+ );
+ if !unavailable.is_empty() {
+ // Named, not counted away. If this list ever covers everything, the
+ // audit has stopped checking existence at all and should say so loudly.
+ println!(
+ " NOT existence-checked here: {} citation(s) in trees absent from this \
+ checkout (nesdev_wiki/ is gitignored upstream corpus). Shape only.",
+ unavailable.len()
+ );
+ assert!(
+ checked > 0,
+ "no citation could be existence-checked at all; if that is genuinely \
+ expected, this audit is now shape-only and should say so in its own name"
+ );
+ }
+}
+
+#[test]
+fn the_extractor_distinguishes_paths_from_identifiers() {
+ // Guards the extractor itself. Without this, widening the filter to "anything
+ // in backticks" would still pass the test above right up until the first
+ // identifier containing a dot, and the failure would be reported as a missing
+ // source file rather than as a broken extractor.
+ let sample = "see `nesdev_wiki/PPU_rendering.xhtml` and `docs/ppu-2c02.md`, \
+ but not `v`, `$2005`, `ppu-state-trace` or `index_framebuffer`";
+ assert_eq!(
+ cited_paths(sample),
+ vec![
+ "docs/ppu-2c02.md".to_owned(),
+ "nesdev_wiki/PPU_rendering.xhtml".to_owned(),
+ ]
+ );
+}
+
+#[test]
+fn a_checkout_without_the_upstream_corpus_still_checks_what_it_has() {
+ // The CI case, driven directly rather than reasoned about. A synthetic root
+ // holding `docs/` but NOT `nesdev_wiki/` is exactly what every runner sees,
+ // because `nesdev_wiki/` is gitignored: 3,407 files of upstream corpus this
+ // repository deliberately does not vendor.
+ //
+ // The first version of this audit checked existence unconditionally. It
+ // passed here, where the corpus is present, and failed every CI job. This
+ // test is the one that would have caught that before pushing.
+ // Per-process, not a fixed global path. Cargo runs tests concurrently, and a
+ // previously aborted run can leave the directory behind -- either way a fixed
+ // name makes this test fail for a reason that has nothing to do with its
+ // subject, which is the worst kind of flake in an audit.
+ let tmp = std::env::temp_dir().join(format!(
+ "rustynes-source-map-audit-ci-shape-{}",
+ std::process::id()
+ ));
+ let _guard = Cleanup(tmp.clone());
+ // NotFound is the expected case on a clean machine, so it is not an error.
+ // Anything else IS: a permission problem here would otherwise surface as a
+ // confusing failure in `create_dir_all` below.
+ match std::fs::remove_dir_all(&tmp) {
+ Ok(()) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => panic!("could not clear {}: {e}", tmp.display()),
+ }
+ std::fs::create_dir_all(tmp.join("docs")).expect("create synthetic docs/");
+ std::fs::write(tmp.join("docs/ppu-2c02.md"), "x").expect("write");
+
+ // A citation naming a DIRECTORY. `exists()` would call this verified;
+ // `is_file()` reports it. Without this case the distinction is untestable --
+ // the real document cites no directory, so mutating `is_file` back to
+ // `exists` comes back NOT CAUGHT, which is how the weaker check survived
+ // review in the first place.
+ std::fs::create_dir_all(tmp.join("docs/subdir")).expect("create synthetic dir");
+ let cited = vec![
+ "docs/ppu-2c02.md".to_owned(), // present -> checked, found
+ "docs/absent.md".to_owned(), // present tree -> checked, MISSING
+ "docs/subdir".to_owned(), // a DIRECTORY -> must be MISSING
+ "nesdev_wiki/PPU_rendering.xhtml".to_owned(), // tree absent -> unavailable
+ ];
+ let c = classify(&tmp, &cited);
+
+ assert_eq!(
+ c.checked, 3,
+ "all three docs/ citations are in a tree that exists"
+ );
+ assert_eq!(
+ c.missing.len(),
+ 2,
+ "a missing file AND a directory-shaped citation must both be failures"
+ );
+ assert!(c.missing.iter().any(|m| *m == "docs/absent.md"));
+ assert!(c.missing.iter().any(|m| *m == "docs/subdir"));
+ assert_eq!(
+ c.unavailable.len(),
+ 1,
+ "a citation into an absent tree is reported, not counted as verified"
+ );
+}
+
+#[test]
+fn a_bare_filename_is_rejected() {
+ // The three real defects this audit caught on its first run were exactly
+ // this shape: `APU_Sweep.xhtml` and two others, sitting in table cells that
+ // listed a second file after a fully-qualified first one. A bare filename is
+ // ambiguous about which tree it lives in, and a source map may not be.
+ //
+ // Driven on synthetic input because the document is now clean: with nothing
+ // to fire on, mutating the check in place is invisible.
+ let clean = vec![
+ "nesdev_wiki/APU_Sweep.xhtml".to_owned(),
+ "docs/apu-2a03.md".to_owned(),
+ ];
+ assert!(bare_filenames(&clean).is_empty());
+
+ let dirty = vec![
+ "nesdev_wiki/APU_Pulse.xhtml".to_owned(),
+ "APU_Sweep.xhtml".to_owned(),
+ "MMC1_pinout.xhtml".to_owned(),
+ ];
+ let found = bare_filenames(&dirty);
+ assert_eq!(
+ found.len(),
+ 2,
+ "both bare filenames must be reported, not just the first"
+ );
+ assert_eq!(found[0], "APU_Sweep.xhtml");
+ assert_eq!(found[1], "MMC1_pinout.xhtml");
+}
+
+#[test]
+fn a_citation_with_an_unrecognised_extension_is_reported_not_dropped() {
+ // The failure mode CodeRabbit named on #447, driven directly: a new citation
+ // whose extension is outside the allow-list must be LOUD, not invisible.
+ // Before this, adding `ref-docs/board.pdf` left the 32 known citations still
+ // clearing the >= 20 threshold, so the audit passed over an unverified
+ // source.
+ let text = "see `nesdev_wiki/PPU_rendering.xhtml` and `ref-docs/board.pdf`";
+ assert_eq!(unrecognised_extensions(text), vec!["ref-docs/board.pdf"]);
+
+ // And a span that is not path-shaped must not be reported as one.
+ let prose = "the `v` register, `$2005`, and `ppu-state-trace`";
+ assert!(unrecognised_extensions(prose).is_empty());
+
+ // A VERSION STRING is the realistic false positive, and these documents are
+ // full of them. `v2.5.3` passes the character set and contains dots, so
+ // without the slash requirement in `unrecognised_extensions` it would be
+ // reported as a citation with an unknown extension -- a hard failure on a
+ // document that is perfectly correct.
+ // A BARE filename with an unrecognised extension. It has no slash, so it
+ // escapes a slash-only path test; and its extension is not in EXTS, so it
+ // escapes `cited_paths`. Without the filename-shape test it vanishes from
+ // both -- the bare-filename blindness again, one extension away.
+ let bare_pdf = "see `board.pdf` for the pinout";
+ assert_eq!(unrecognised_extensions(bare_pdf), vec!["board.pdf"]);
+ assert!(cited_paths(bare_pdf).is_empty());
+
+ let versions = "shipped in `v2.5.3`, built on `v2.4.9`";
+ assert!(
+ unrecognised_extensions(versions).is_empty(),
+ "a version string is not a path-like citation"
+ );
+ assert!(
+ cited_paths(versions).is_empty(),
+ "and it is not a citation either"
+ );
+}
+
+#[test]
+fn a_bare_filename_survives_extraction_and_is_then_reported() {
+ // END TO END, through `cited_paths` -- not a hand-built vector fed to the
+ // predicate. That distinction is the whole finding: with a slash filter in
+ // the shared extractor, `cited_paths` silently dropped bare filenames, so
+ // `bare_filenames` evaluated an empty list and passed forever. The audit was
+ // structurally blind to the exact defect it claims to catch, and the
+ // predicate-level test could not see it because it bypassed the extractor.
+ let doc = "| Pulse | `nesdev_wiki/APU_Pulse.xhtml`, `APU_Sweep.xhtml` |";
+ let cited = cited_paths(doc);
+ assert!(
+ cited.iter().any(|c| c == "APU_Sweep.xhtml"),
+ "the extractor must SURFACE a bare filename, not drop it: {cited:?}"
+ );
+ assert_eq!(
+ bare_filenames(&cited),
+ vec![&"APU_Sweep.xhtml".to_owned()],
+ "and the shape check must then report it"
+ );
+
+ // The real table cell that produced the original three defects has exactly
+ // this shape: a fully-qualified path followed by a bare one.
+ assert!(cited.iter().any(|c| c == "nesdev_wiki/APU_Pulse.xhtml"));
+}
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 8a075512..9b760a1f 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -1,6 +1,6 @@
# RustyNES — Project Status Matrix
-> **Current release: v2.5.0** (2026-08-23) — **"Rungwork"**, 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: v2.5.1** (2026-08-23) — **"Retrace"**, 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.
diff --git a/docs/adr/0038-cosim-interrupt-injection-api.md b/docs/adr/0038-cosim-interrupt-injection-api.md
index 90aa39ca..8ae46c30 100644
--- a/docs/adr/0038-cosim-interrupt-injection-api.md
+++ b/docs/adr/0038-cosim-interrupt-injection-api.md
@@ -95,22 +95,73 @@ constraints. **Each one is a condition of acceptance, not a recommendation.**
field and every injected branch is behind `#[cfg(feature =
"cosim-interrupt-inject")]`, so a default build emits none of it. Verify by
grepping the expanded source:
- `cargo expand -p rustynes-core --lib 2>/dev/null | grep -c inject_` **must be
- 0** for the default build. This is a proof, not a sample.
-
- **2b — corroborating measurement.** Against a `main` baseline on the *same
- runner in one session*, criterion's default 100 samples:
-
- ```console
- git worktree add /tmp/bench-main main
- cargo bench -p rustynes-core --bench full_frame -- --save-baseline main # in the worktree
- cargo bench -p rustynes-core --bench full_frame -- --baseline main # in the branch, feature OFF
+ **must be 0** for the default build. This is a proof, not a sample.
+
+ **Do not run it as `cargo expand ... 2>/dev/null | grep -c inject_`.** That
+ was this ADR's first wording and it is a trap: `cargo-expand` is a separate
+ binary and is not installed on this workstation, so the redirect swallows
+ "no such command", `grep -c` counts an empty stream, and the gate reports the
+ **0 it is looking for** while measuring nothing. Use the expander that ships
+ with the toolchain, and always read the control first:
+
+ ```bash
+ # Each expansion is captured and its STATUS checked before anything is
+ # counted. Piping cargo straight into `grep -c` hides a failed expansion the
+ # same way the `cargo expand` version hid a missing binary: the count comes
+ # back 0 and the gate reads that as success. `grep -c` is deliberately NOT
+ # used under `set -e` either -- zero matches exits 1, which would abort the
+ # very case the gate is looking for.
+ expand() {
+ local out
+ out=$(cargo +nightly rustc -p rustynes-core --lib --profile check "$@" \
+ -- -Zunpretty=expanded 2>/dev/null) || return 1
+ [ -n "$out" ] || return 1 # an empty expansion is not a clean one
+ printf '%s\n' "$out" | grep -c inject_ || true
+ }
+ off=$(expand) || { echo "default expansion FAILED -- gate not run" >&2; exit 1; }
+ on=$(expand --features cosim-interrupt-inject) \
+ || { echo "feature expansion FAILED -- gate not run" >&2; exit 1; }
+ # off must be 0 AND on must be > 0. A zero `on` means the instrument is dead,
+ # not that the feature is clean -- which is exactly how the first run of this
+ # gate passed twice while measuring nothing at all.
+ [ "$off" -eq 0 ] && [ "$on" -gt 0 ] || { echo "gate FAILED: off=$off on=$on" >&2; exit 1; }
```
- **Pass: all four `full_frame` workloads within ±1.0%.** If 2a is 0 and 2b
- exceeds that band, the finding is the measurement environment, not the
- feature — record it and re-run rather than accepting a number that cannot be
- caused by code that does not exist.
+ Measured 2026-08-23: **off = 0, on = 17.**
+
+ **2b — a calibrated same-tree control, NOT a cross-tree comparison.**
+
+ The first draft of this gate said "against a `main` worktree baseline". That
+ instrument was tried and is **invalid**, and the measurement that retired it is
+ worth keeping: the default build measured **+3.3%** on `flowing_palette`
+ against a `main` worktree whose source, post-`cfg`-expansion, is *provably
+ identical* — every added line in `bus.rs` is behind the feature gate, and 2a
+ reports 0. Two builds of the same code in different absolute paths differ in
+ embedded strings and therefore in layout, and that is what was being measured.
+
+ Reproducible, too: two runs gave +2.9% and +3.3%, so it is not thermal drift.
+ A stable number from an instrument that cannot be measuring what it claims is
+ worse than a noisy one, because it invites exactly the argument this
+ constraint exists to prevent.
+
+ What is valid, **measured on this implementation**:
+
+ | measurement | result |
+ |---|---|
+ | 2a — expanded-source `inject_` count, default build (command above) | **off = 0, on = 17** — decisive |
+ | Same-tree noise floor (baseline vs itself) | **±1.2%** — the calibrated band |
+ | Same-tree A/B, feature ON vs OFF | +1.3% / −1.4% / +1.7% / −1.0% — **mixed signs, no consistent direction**; three of the four marginally EXCEED the ±1.2% floor |
+
+ The A/B row previously read "inside the floor". It is not: +1.3, −1.4 and +1.7
+ all lie outside ±1.2. The claim was wrong and is corrected rather than the
+ band being widened to fit it. What the row actually shows is a signal with no
+ consistent direction, which is what a null looks like on an instrument whose
+ floor is about this size -- and it is moot either way, because 2a is 0.
+
+ **Pass: 2a is 0.** The rest is calibration, not a gate — because when 2a is 0
+ the off build *is* the previous build, and there is no quantity left to
+ measure. The ON-vs-OFF row is recorded for the co-simulation crate's own
+ information, not as a merge condition.
Both are *preconditions of merging*, not follow-ups.
diff --git a/docs/mister.md b/docs/mister.md
index c7247765..f4cb0800 100644
--- a/docs/mister.md
+++ b/docs/mister.md
@@ -3,11 +3,46 @@
**Spec, not history.** Update this file in the same change as any behaviour
change to `crates/rustynes-cosim` or the golden formats it emits.
-**Decision record:** [ADR 0037](adr/0037-mister-fpga-core-independent-hdl-implementation.md).
-**Execution plan:** [`to-dos/plans/v2.5.0-fabric-plan.md`](../to-dos/plans/v2.5.0-fabric-plan.md).
-**Research archive:** [`to-dos/plans/research/v2.5.0-research-mister-fpga.md`](../to-dos/plans/research/v2.5.0-research-mister-fpga.md).
+**Decision records:** [ADR 0037](adr/0037-mister-fpga-core-independent-hdl-implementation.md)
+(the programme and the HDL firewall) ·
+[ADR 0038](adr/0038-cosim-interrupt-injection-api.md) (the interrupt-injection API).
+**Execution plan:** [`to-dos/plans/v2.7.0-mister-core-plan.md`](../to-dos/plans/v2.7.0-mister-core-plan.md)
+-- **supersedes** [`v2.5.0-fabric-plan.md`](../to-dos/plans/v2.5.0-fabric-plan.md),
+which is delivered.
+**Execution tracking:** [`to-dos/mister/`](../to-dos/mister/).
+**Research archive:** [`to-dos/plans/research/v2.5.0-research-mister-fpga.md`](../to-dos/plans/research/v2.5.0-research-mister-fpga.md),
+plus four dated files in `ref-docs/` (contribution requirements, the MiSTer
+framework, the hardware **source map**, and alternative FPGA targets).
**Device-under-test:** (private).
+## Rung 2 is closed (v2.5.1)
+
+Its interrupt half was the last piece. `tb/interrupt_sweep.py` asserts /NMI,
+/IRQ, or **both together** before instruction K and holds it, for every K across
+a hazard program, driving identical stimulus into both sides: **60 injection
+points, 0 divergences** on all seven CPU fields.
+
+It found two defects, and the second is the one worth remembering. A hardware
+interrupt pushed a return address **one byte too high**, because `AM_BRK` fell
+through a generic operand-fetch increment shared with every other addressing
+mode. `BRK` and a hardware interrupt share that mode and *disagree* about it --
+`BRK` advances over its second byte, an interrupt does not -- so for `BRK` two
+writers assigned the same value and the fault was invisible. **`BRK` passing
+186/186 is what kept it hidden**: the only opcode exercising the mode was the one
+on which the bug did not show.
+
+The oracle side is [ADR 0038](adr/0038-cosim-interrupt-injection-api.md)'s
+`cosim-interrupt-inject` feature. Its precondition -- that a default build emits
+none of it -- is **measured, with a live control**: `inject_` appears 0 times in
+the expanded default core and **17** times with the feature on. The control is not
+ceremony. The ADR's original command piped a missing `cargo-expand` through
+`grep -c`, which reports the 0 it is looking for while measuring nothing.
+
+**Two v2.5.0 gates remain open and are reclassified, not carried.** nestest 0-diff
+and the 5 M-cycle window both stop at a `$2002` read where *both sides address it*
+and only the data differs -- the DUT has no PPU. They are rung-3 acceptance
+criteria.
+
---
## What this is, and what it is not
diff --git a/ref-docs/2026-08-23-alternative-fpga-targets.md b/ref-docs/2026-08-23-alternative-fpga-targets.md
new file mode 100644
index 00000000..e57b25c7
--- /dev/null
+++ b/ref-docs/2026-08-23-alternative-fpga-targets.md
@@ -0,0 +1,89 @@
+# Alternative homes for the core: Retro Remake, openFPGA, and why they are planned rather than contingent
+
+**Dated supplemental reference, 2026-08-23.** `ref-docs/` is immutable.
+
+**Sources**, searched and fetched 2026-08-23: `retroremake.co` ·
+`retrorgb.com/mister-superstation-one-review.html` ·
+`analogue.co/developer/docs/overview` ·
+`openfpga-library.github.io/analogue-pocket/` ·
+`timeextension.com` (openFPGA core index) · `retrorgb.com` (Neo Geo core ported to
+Analogue Pocket).
+
+---
+
+## Why this file exists
+
+The prior plan ranked *"the core is declined as a duplicate"* as risk 5, and it is
+the one risk this project cannot mitigate by working harder: `NES_MiSTer` exists,
+is competent, and MiSTer discourages redundant cores. **The honest response is to
+know the alternatives before submitting, not after being declined** — which also
+means the RTL should not accumulate MiSTer-only assumptions it does not need to.
+
+---
+
+## 1. Retro Remake — SuperStation One
+
+A Cyclone V console with **128 MB integrated BGA SDRAM**, from the makers of
+MiSTer Pi, shipping through 2026. Marketed as a PS1-style machine that is
+**fully compatible with MiSTer FPGA cores** — load a core and it runs.
+
+**Three consequences, all favourable:**
+
+1. **It removes a hardware prerequisite.** A DE10-Nano needs the SDRAM add-on for
+ any NES core; the SS1 has it on the motherboard. Bring-up is cheaper here.
+2. **Distribution reaches both.** Retro Remake forks `Distribution_MiSTer` and
+ `Downloader_MiSTer`.
+3. **It is a second home.** Retro Remake maintains its own public repositories and
+ hosts cores itself, so a core declined by MiSTer-devel still reaches real users.
+
+**The claim worth verifying rather than inheriting:** sources say SS1 runs MiSTer
+cores "without modifications", but none confirms the *identical bitstream*
+byte-for-byte. Rung 6 tests that one `.rbf` boots both boards. Any divergence is a
+publishable finding, and that is a reason to own both.
+
+## 2. Analogue Pocket — openFPGA
+
+Analogue's openFPGA opens the Pocket's FPGA to third-party cores, with public
+developer documentation. **MiSTer cores have a demonstrated porting path**:
+Furrtek's Neo Geo core was ported by UltraFP64, and there are openFPGA ports of
+the MiSTer ZX Spectrum core and others. There is an active core index
+(`openfpga-library.github.io/analogue-pocket`).
+
+**What a port would require, and why it is not free:**
+
+- A different framework — openFPGA's own host interface, not `sys/`/`hps_io`.
+- Different video and audio plumbing.
+- A different (smaller) FPGA budget, so a core that only just closes timing on
+ Cyclone V is not automatically portable.
+
+**What survives a port unchanged:** `rtl/cpu6502.sv`, `rtl/ppu2c02.sv`,
+`rtl/apu2a03.sv`, the cartridge/mapper modules — everything that is *the NES*
+rather than *the platform*. **And the entire co-simulation apparatus**, which is
+platform-independent by construction: it drives `nes_top`, never `emu`.
+
+**Design rule this implies, and it costs nothing to follow:** keep every MiSTer
+assumption inside `emu`/`sys/`-facing code, and keep `nes_top.sv` a plain
+NES-shaped module with a cartridge interface, a video output and an audio output.
+The testbench already forces this discipline, since it instantiates `nes_top`
+directly.
+
+## 3. MiSTeX
+
+Searched; no authoritative current source surfaced in this pass. Recorded as
+**unverified** rather than described from memory — a project this file cannot cite
+should not appear in it as fact. Worth a second look before v2.7.0 if the primary
+route is declined.
+
+---
+
+## 4. What this means for the plan
+
+- **The alternatives are real, and one of them is already the hardware target.**
+ That materially reduces risk 5 from "the work may be wasted" to "the work has at
+ least two homes".
+- **The portability rule above is free** — it is the module boundary the
+ co-simulation harness already enforces — so it should be stated as a standing
+ design constraint rather than a porting task.
+- **The evidence apparatus retains value independently of any of them.** A
+ per-cycle co-simulation record against a 141/141 emulator is publishable on its
+ own terms, and is the deliverable that cannot be declined.
diff --git a/ref-docs/2026-08-23-fpga-nes-hardware-source-map.md b/ref-docs/2026-08-23-fpga-nes-hardware-source-map.md
new file mode 100644
index 00000000..49be1105
--- /dev/null
+++ b/ref-docs/2026-08-23-fpga-nes-hardware-source-map.md
@@ -0,0 +1,101 @@
+# The permitted sources for the 2C02, 2A03 and top-six mappers — a source map, not a summary
+
+**Dated supplemental reference, 2026-08-23.** `ref-docs/` is immutable.
+
+## Why this is a map and not a summary
+
+Under the provenance firewall (ADR 0037), the RTL for the PPU, APU and mappers may
+be written **only** from public documentation. This file exists to make that
+concrete: it names the exact page, locally present, for each behaviour the RTL has
+to implement — so that "written from documentation" is a checkable claim rather
+than an assertion.
+
+**It deliberately does not restate the hardware behaviour.** A paraphrase here
+would become a third source that drifts from both the wiki and
+`docs/ppu-2c02.md`, and this project has already published a false claim assembled
+from two true statements nobody re-read together. Read the cited page.
+
+**All paths are relative to the repository root.** The corpus is 3,407 files;
+these are the ones that matter for v2.5.1 → v2.7.0.
+
+---
+
+## Rung 3 — the 2C02
+
+| Behaviour | Primary source | Project cross-reference |
+|---|---|---|
+| Per-dot rendering pipeline, the 341×262 grid | `nesdev_wiki/PPU_rendering.xhtml` | `docs/ppu-2c02.md` |
+| `v`/`t`/`x`/`w`, the `$2005`/`$2006` sequence, mid-frame scroll | `nesdev_wiki/PPU_scrolling.xhtml` | `docs/ppu-2c02.md` |
+| **Sprite evaluation, per-dot OAM access** | `nesdev_wiki/PPU_sprite_evaluation.xhtml` | `docs/ppu-2c02.md` |
+| Register side effects, the `$2002` read race | `nesdev_wiki/PPU_registers.xhtml` | `docs/ppu-2c02.md` |
+| Nametable layout and mirroring | `nesdev_wiki/PPU_nametables.xhtml` | `docs/mappers.md` |
+| Pattern-table addressing | `nesdev_wiki/PPU_pattern_tables.xhtml` | — |
+| Palette RAM, mirrors, backdrop override | `nesdev_wiki/PPU_palettes.xhtml` | `docs/ppu-2c02.md` |
+| VBlank/NMI timing, **odd-frame skip** | `nesdev_wiki/PPU_frame_timing.xhtml` | `docs/scheduler.md` |
+| Power-on state | `nesdev_wiki/PPU_power_up_state.xhtml` | `docs/ppu-2c02.md` |
+
+**Sprite evaluation is the hardest single item in the programme**, and it is where
+the firewall is under most pressure — see the risk table in the plan. Its gate is
+`index_framebuffer` plus the sprite-0/overflow ROMs, **never** `ppu-state-trace`,
+which encodes RustyNES's FSM rather than hardware.
+
+## Rung 4 — the 2A03
+
+| Behaviour | Primary source |
+|---|---|
+| Register map | `nesdev_wiki/APU_registers.xhtml` |
+| Frame counter, its IRQ, the 4/5-step sequence | `nesdev_wiki/APU_Frame_Counter.xhtml` |
+| Pulse channels | `nesdev_wiki/APU_Pulse.xhtml`, `nesdev_wiki/APU_Sweep.xhtml`, `nesdev_wiki/APU_Envelope.xhtml` |
+| Triangle | `nesdev_wiki/APU_Triangle.xhtml` |
+| Noise, the LFSR | `nesdev_wiki/APU_Noise.xhtml` |
+| DMC, and its **DMA stealing back into the CPU** | `nesdev_wiki/APU_DMC.xhtml` |
+| Length counters | `nesdev_wiki/APU_Length_Counter.xhtml` |
+| Period tables | `nesdev_wiki/APU_period_table.xhtml` |
+| Status/`$4015` | `nesdev_wiki/APU_Status.xhtml` |
+| Mixing | `nesdev_wiki/APU_Mixer.xhtml` |
+
+Project cross-reference throughout: `docs/apu-2a03.md`.
+
+**The mixer pages are reference only, not a gate.** Rung 4 compares the integer
+channel levels in `MixRecord`; RustyNES's non-linear mixer and BLEP resampler are
+software artifacts with no hardware counterpart, and gating on the mixed `f32`
+would either force the RTL to reproduce them or produce permanent unresolvable
+false failures.
+
+## Rung 7 — the top six mappers
+
+| Board | Source | Notes |
+|---|---|---|
+| NROM | `nesdev_wiki/NROM.xhtml` | 327 Kb, fits on-chip; no SDRAM needed |
+| MMC1 | `nesdev_wiki/MMC1.xhtml`, `nesdev_wiki/MMC1_pinout.xhtml` | Serial shift register; the WRAM write-protect layers RustyNES closed in v2.2.3 |
+| UxROM | `nesdev_wiki/UxROM.xhtml` | Simple PRG banking |
+| CNROM | `nesdev_wiki/CNROM.xhtml` | Simple CHR banking |
+| AxROM | `nesdev_wiki/AxROM.xhtml` | PRG banking + one-screen mirroring |
+| **MMC3** | `nesdev_wiki/MMC3.xhtml` | **A12 filtering and the IRQ counter — the one with substance** |
+
+MMC3's A12 behaviour is the item most likely to need iteration. RustyNES's own
+implementation and the shared MMC3-clone timing oracle are in `docs/mappers.md`;
+the test ROMs are `tests/roms/mmc3_test_2/` and `tests/roms/mmc1_a12/`.
+
+---
+
+## Independent oracles available per rung
+
+The plan's risk 6 is *"the oracle can be wrong"* — 141/141 on AccuracyCoin is not
+"matches silicon". Every rung is therefore labelled by whether it has a source of
+truth **independent of RustyNES**:
+
+| Rung | Independent oracle | Where |
+|---|---|---|
+| 1–2 (6502) | nestest, 0-diff against **Nintendulator** | `tests/roms/nestest/` |
+| 2 (interrupts) | **`cpu_interrupts_v2`** — but it uses the APU frame IRQ, so it only becomes runnable at rung 4 | `tests/roms/blargg/cpu_interrupts_v2/` |
+| 3 (PPU) | blargg PPU timing and sprite ROMs | `tests/roms/blargg/` |
+| 4 (APU) | `apu_test`, `apu_mixer`, `dmc_dma_during_read4` | `tests/roms/blargg/` |
+| 5 (system) | AccuracyCoin | `tests/roms/accuracycoin/` |
+| 7 (mappers) | `holy_mapperel`, `mmc3_test_2`, `mmc1_a12` | `tests/roms/` |
+
+**Worth noting for v2.5.1:** `cpu_interrupts_v2` is a genuine third-party oracle
+for interrupt behaviour, and it does not depend on the ADR 0038 injection API. It
+does depend on the APU frame IRQ, so it cannot run until rung 4 — which means the
+interrupt work is verified twice, by different means, at two different points in
+the line. That is worth more than either alone.
diff --git a/ref-docs/2026-08-23-mister-core-contribution-requirements.md b/ref-docs/2026-08-23-mister-core-contribution-requirements.md
new file mode 100644
index 00000000..dca17f61
--- /dev/null
+++ b/ref-docs/2026-08-23-mister-core-contribution-requirements.md
@@ -0,0 +1,96 @@
+# MiSTer FPGA core contribution — requirements, process, and what they mean for RustyNES
+
+**Dated supplemental reference, 2026-08-23.** `ref-docs/` is immutable; corrections
+land as a new dated file, never as an edit to this one.
+
+**Primary source:** the MiSTer-devel wiki page
+*Contributing a Core to MiSTer FPGA*
+(),
+fetched 2026-08-23. Every requirement below is quoted or paraphrased from it
+rather than recalled, because the whole point of this file is that the RTL and the
+release process are built against the real bar.
+
+---
+
+## 1. What the core must demonstrate
+
+> The core must demonstrate **preservation value** through accurate implementation
+> of the original system.
+
+MiSTer discourages redundant cores. `NES_MiSTer` already exists, is GPL-3.0,
+covers ~150 mappers and FDS, and scores **121/125 on AccuracyCoin** — where real
+Famicom AV hardware also scores ~121/125. **There is no published accuracy
+headroom**, and this must be understood as a fact about the submission rather
+than a problem to solve. See §6.
+
+## 2. The AI-generated-code bar, verbatim
+
+> Fully AI generated code should meet a **minimum reasonable bar for readability**
+> and include **some evidence of quality and accuracy testing**.
+
+This is the single most important sentence on the page for this project, and it is
+favourable. The co-simulation apparatus — a per-cycle bus comparison against a
+141/141 AccuracyCoin emulator, with every gate demonstrated to fail by mutation
+before it is trusted — **is** that evidence, in a form no incumbent core can
+currently show. The contribution case is built on this, not on coverage.
+
+## 3. Licensing
+
+> Publish under compatible open-source licenses, such as **GPLv3 or MIT**.
+
+`RustyNES_MiSTer` is **GPL-3.0-or-later**. Settled, and already forced upward
+rather than chosen: the v2.4.3 `sys/` licence audit found 57 files, **zero
+GPL-2.0-only**, and `hps_io.sv` is GPL-3.0-or-later and **not optional** — it is
+how a core receives a ROM from the HPS and reaches the OSD. The combined bitstream
+must therefore be GPL-3.0-or-later. No relicensing is needed or possible.
+
+## 4. Repository layout
+
+| Path | Contents |
+|---|---|
+| `sys/` | The MiSTer framework, **verbatim** |
+| `rtl/` | Core implementation |
+| `releases/` | Binary releases (and MRA files, arcade only) |
+
+Plus, at the repository root: `.qpf`, `.qsf`, `.srf`, `.sdc`, the top-level `.sv`,
+`files.qip`, `clean.bat`, `.gitignore`.
+
+**`sys/` may not be modified.** Framework updates overwrite local changes, and all
+cores are expected to carry it unchanged. `RustyNES_MiSTer/sys/` is currently an
+empty placeholder (`.gitkeep` + `README.md`) — populating it verbatim from
+`Template_MiSTer` is a rung-6 task, not an earlier one.
+
+## 5. Release naming
+
+- Non-arcade: `_YYYYMMDD.rbf` → **`RustyNES_YYYYMMDD.rbf`**
+- Arcade: `Arcade-_YYYYMMDD.rbf` — **not applicable here**
+
+**MRA files are arcade-only** and do not apply to a console core. Non-arcade cores
+must instead specify a **unique Home folder** when added to the Cores list.
+
+## 6. Submission process
+
+1. Email **`newcores@misterfpga.org`** with a link to the GitHub repository.
+2. Await review — the page says **within days**.
+3. Accept the invitation to the **MiSTer-devel** organisation.
+4. **Transfer the repository** to MiSTer-devel. *You remain the primary
+ maintainer.*
+5. Add the core to the Cores list, specifying the unique Home folder.
+
+Step 4 is worth reading twice before submitting: acceptance means the repository
+moves. That is a one-way action on a repo this project owns, and it should be a
+deliberate decision at v2.7.0 rather than a reflex.
+
+## 7. What this means for the v2.5.1 → v2.7.0 line
+
+- The layout requirements are cheap and late — `sys/`, `files.qip`, `.sdc`,
+ `clean.bat` are all rung-6 work, and none of them gate the PPU or APU rungs.
+- The **licence question is already closed**, which removes what the prior plan
+ ranked as risk 1.
+- The **accuracy bar is where the effort goes**, and the evidence apparatus is
+ what distinguishes this submission. That argues for gating hard on AccuracyCoin
+ entry-for-entry parity (rung 5) even though a pass *count* would be easier to
+ report.
+- **The core may still be declined as a duplicate.** Retro Remake and openFPGA are
+ planned alternative homes; see the companion file
+ `2026-08-23-alternative-fpga-targets.md`.
diff --git a/ref-docs/2026-08-23-mister-framework-reference.md b/ref-docs/2026-08-23-mister-framework-reference.md
new file mode 100644
index 00000000..f070eb82
--- /dev/null
+++ b/ref-docs/2026-08-23-mister-framework-reference.md
@@ -0,0 +1,111 @@
+# The MiSTer framework: `emu`, `sys/`, `hps_io`, video, audio and memory
+
+**Dated supplemental reference, 2026-08-23.** `ref-docs/` is immutable.
+
+**Primary sources**, fetched 2026-08-23:
+`https://mister-devel.github.io/MkDocs_MiSTer/developer/emu/` ·
+`.../developer/hps_io/` · `.../developer/conf_str/` · `.../developer/porting/` ·
+`https://github.com/MiSTer-devel/Template_MiSTer`.
+
+This is rung-6 material. It is written down now so the PPU and APU rungs can be
+designed with the integration constraints known, rather than discovering at
+v2.6.5 that a video path has to be rebuilt.
+
+---
+
+## 1. The core is not the top level
+
+A MiSTer core implements a module named **`emu`**, which `sys_top.v` instantiates.
+The framework — not the core — owns HDMI scaling, the OSD, audio output and input
+handling. Everything under `sys/` is identical across cores and **must not be
+modified**.
+
+The practical consequence for this project: **`nes_top.sv` is not the deliverable
+top level.** It becomes an inner module of `emu`, and the co-simulation testbench
+keeps driving `nes_top` directly — which is exactly the separation ADR 0037 wants,
+since `tb/` is never in `files.qip`.
+
+## 2. Clocking
+
+- **`CLK_50M`** is the board reference, fed to a PLL that the framework expects to
+ be named `pll` with instance name `pll`.
+- **`CLK_AUDIO`** is a fixed 24.576 MHz reference.
+
+The Fabric plan already fixed the core's internal clocking: a single
+**21.477272 MHz `clk_sys`** with a **mod-12 master phase counter** (÷4 → PPU dot,
+÷12 → CPU cycle, low/high halves giving M2 phase). That is derived from the PLL
+here; nothing about this framework requirement changes it.
+
+## 3. Video — mandatory
+
+| Signal | Meaning |
+|---|---|
+| `CLK_VIDEO` | Base pixel clock, typically `clk_sys` |
+| `CE_PIXEL` | Clock enable derived from `CLK_VIDEO` — this is how variable resolutions work |
+| `VGA_R/G/B` | 8-bit per channel |
+| `VGA_HS/VS` | Sync |
+| `VGA_DE` | Display enable, `~(HBlank \| VBlank)` |
+| `VGA_F1` | Interlace field |
+| `VGA_SL[1:0]` | Scanline control |
+| `VIDEO_ARX/ARY[12:0]` | Aspect ratio; bit 12 set means bits [11:0] are scaled dimensions |
+
+**Note for this core:** the NES is 256×240 at 8:7 pixel aspect. RustyNES's own
+desktop frontend applies 8:7, and the libretro wrapper shipped `aspect_ratio = 0.0`
+(square pixels) as a defect until v2.3.5 — so this is a known trap in this project
+specifically, and `VIDEO_ARX/ARY` must be set deliberately rather than defaulted.
+
+The optional framebuffer path (`MISTER_FB`) is **not** wanted: it renders through
+DDRAM and is for cores that produce frames rather than scanlines. A cycle-accurate
+PPU produces pixels at `CE_PIXEL`, which is the direct path.
+
+## 4. Audio — mandatory
+
+`AUDIO_L/R[15:0]`, `AUDIO_S` (1 = signed), `AUDIO_MIX[1:0]` (0/25/50/100% mono
+blend).
+
+**Design consequence, and it matters for rung 4.** The framework takes 16-bit
+integer samples. RustyNES's non-linear mixer and BLEP resampler are *software*
+artifacts with no hardware counterpart — which is why rung 4 gates on the
+**integer channel levels** in `MixRecord` (pulse/triangle/noise 0–15, DMC 0–127)
+and never on the mixed `f32`. The RTL produces its own mix into these 16 bits; the
+oracle comparison stops at the channel level.
+
+## 5. `hps_io` — mandatory
+
+`HPS_BUS[45:0]` is passed straight into `hps_io`, which abstracts ARM
+communication: ROM download, status bits from the OSD, buttons, joysticks, RTC,
+and the `CONF_STR` menu. It is how a core receives a cartridge at all.
+
+`CONF_STR` is the OSD menu definition — a string of options the framework parses.
+For this core it will carry at minimum: region (NTSC/PAL/Dendy), aspect ratio,
+scanline/blend video options, and reset.
+
+## 6. Memory — SDRAM versus DDR3
+
+| | SDRAM | DDR3 (DDRAM) |
+|---|---|---|
+| Latency | Low, deterministic | ~20+ cycles |
+| Interface | Direct address/data bus | Request/response with `DDRAM_BUSY`, burst up to 128 words |
+| Suits | Cartridge ROM read on demand | Bulk/streaming, framebuffers |
+
+**A NES core needs SDRAM.** The CPU and PPU read cartridge ROM directly, on
+demand, with no tolerance for a 20-cycle response — which is why the DE10-Nano
+**SDRAM add-on board is mandatory** for any NES core and why the SuperStation One
+(128 MB integrated) avoids the prerequisite entirely.
+
+Signals: `SDRAM_CLK/CKE/A[12:0]/BA[1:0]/DQ[15:0]/DQML/DQMH/nCS/nCAS/nRAS/nWE`.
+
+**Scope note:** NROM is 327 Kb and fits entirely in on-chip M10K. Rungs 3–6 need
+**no external memory at all**. The SDRAM controller is a rung-7 item, forced by
+MMC3's 6 Mb — which is why the plan puts hardware bring-up (rung 6) *before* it.
+
+## 7. Other interfaces, all optional here
+
+`UART_*`, `SD_*`, `ADC_BUS`, `USER_IN/OUT` — none needed for an NES core.
+`LED_USER`/`LED_POWER`/`LED_DISK` and `BUTTONS` are optional but cheap.
+
+## 8. Optional helper modules
+
+`video_mixer`, `video_freak`, `arcade_video` provide gamma, scaling and
+scandoubling. Optional for basic VGA output; `video_mixer` is the conventional
+choice for a console core and is worth using rather than reimplementing.
diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md
index 9cd65123..2f41a88c 100644
--- a/to-dos/ROADMAP.md
+++ b/to-dos/ROADMAP.md
@@ -55,11 +55,12 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**.
## Status
-- **Current release:** **RustyNES 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 seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. The emulation core is untouched. Built on **v2.4.3 "Touchstone"** (2026-08-22) — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched. Built on **v2.4.2 "Cairn"** (2026-08-22) — the **rung-0 compare surface**: rolling per-cycle hash checkpoints, measured at **15,263x** smaller than the equivalent CSV; the v2.4.2 acceptance gate made executable; and the partition between what RustyNES *models* and what a device can *observe*. Built on **v2.4.1 "Fabric"** (2026-08-20) — the **oracle** release, opening the **v2.4.1 → v2.5.0 "Fabric"** line: a new NES core written in SystemVerilog from public hardware documentation, in a sibling repository, with this emulator as its **verification oracle**. RustyNES is not being ported to FPGA and cannot be; `crates/rustynes-cosim` is the boundary (a narrow C ABI a Verilator testbench links, plus `nes_golden_export`), and the provenance firewall extends to HDL per ADR 0037. **v2.5.0 is scoped to "the 6502 rung closes"**, not a finished core. Excluding the crate from the workspace is the load-bearing detail — cargo unifies features, `irq-timing-trace` selects a *different* per-dot loop in `Bus::tick_one_cpu_cycle`, and the accuracy battery had been validating a scheduler no user runs. It also carries **v2.4.0 "Concordance"**, which merged to `main` and was never tagged: atomic durable writes on every path that persists user data, `Nes::timeline_generation()`, and the 15-anchor release audit. AccuracyCoin **141/141** verified, not asserted. Built on **v2.3.9 "Crucible"** (2026-08-20) — the **gates** release. A crucible tests to destruction rather than inspects, and that is what this release does to the project's own checks: what they cover, what they only *appear* to cover, and where a regression could still reach `main` unchallenged. The v2.3.x line added five tools in four releases, and the recurring finding across all of them was never that the emulation was wrong — it was that **a check reported a pass it had not earned**. **The docs-only CI skip had never worked**: `dorny/paths-filter`’s `predicate-quantifier` defaults to `some`, so the `code` filter’s leading `'**'` matched everything and all seven `!` exclusions under it were dead from the day they were written — a markdown-only PR logged `Filter code = true`. Fixed with **two** filter steps, because the quantifier is step-level and `accuracy` is a list of *alternatives* that becomes unsatisfiable under `every`: the naive one-line fix would have silently disabled the accuracy battery while repairing a different gate. **`test-roms` now runs at review time**, path-filtered over the chip crates, the core, `rustynes-gamedb`, the harness and `tests/` — measured first at 11 of the last 40 merged PRs, so ~72% still pay nothing. **A freeze from one cartridge kept writing into the next** — not a stale label but an active per-frame write into the wrong game, closed by a ROM-transition sweep across every panel under one rule: derived output is discarded, user-authored input is kept, and only input that actively *writes* is neutralised. **The config file is now written atomically and durably** (seven properties, five of them from review rather than the first draft). Plus **257 lines of dead code removed**, the SAFETY-comment rule made a clippy gate (`undocumented_unsafe_blocks`, demonstrated to fail), and two `cargo deny` advisory ignores retired on their own stated condition. `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted.** **`docs/STATUS.md` is the authoritative current-state record.**
+- **Current release:** **RustyNES 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 seventeen single-byte implied opcodes, in SystemVerilog in the sibling repository (`RustyNES_MiSTer@7f092bd`), matching the oracle on all seven CPU fields -- 29 records, and the gate demonstrated to fail on four mutations. The DUT is the **third writer** of the oracle's `CpuBootTrace` format, so `cpu_boot_trace_diff` reads it with no modification and the rung needed no oracle-side change at all. **The oracle settled a question our own prose could not**: reset is EIGHT cycles, and `docs/cpu-6502.md` said both seven and eight -- corrected here. The emulation core is untouched. Built on **v2.4.3 "Touchstone"** (2026-08-22) — what the synthesiser accepts, and what the licence requires. A touchstone is a stone you rub gold against; the streak tells you what the metal actually is. This release settles the **two Fabric-plan risks that had to be answered before any RTL exists**, and both were answered by evidence that contradicted what the plan assumed. **Risk 4, the Quartus subset, is FITTED**: Quartus Prime Lite 17.0.2 Build 602 on a 5CSEBA6U23I7 produced a placed-and-routed netlist with **0 synthesis warnings**, and the 2 KiB array inferred as **2 M10K blocks with 29 total registers** — not 16,413 — from the source style alone, no `ramstyle` attribute. The `initial` block became a real MIF (so a boot ROM lands inside the block) and the `enum` was one-hot encoded. Nine constructs are promoted to *fitted*; plain `case`, `priority case` and `$bits` are deliberately left *documented* because the kitchen sink does not exercise them. **Risk 1, the `sys/` licence, inverts the plan's own hedge**: 57 files, **zero GPL-2.0-only**, and `hps_io.sv` — GPL-3.0-or-later and not optional, since it is how a core receives a ROM and reaches the OSD — forces the combined bitstream **up** to GPL-3.0-or-later, already RustyNES's licence. The emulation core is untouched. Built on **v2.4.2 "Cairn"** (2026-08-22) — the **rung-0 compare surface**: rolling per-cycle hash checkpoints, measured at **15,263x** smaller than the equivalent CSV; the v2.4.2 acceptance gate made executable; and the partition between what RustyNES *models* and what a device can *observe*. Built on **v2.4.1 "Fabric"** (2026-08-20) — the **oracle** release, opening the **v2.4.1 → v2.5.0 "Fabric"** line: a new NES core written in SystemVerilog from public hardware documentation, in a sibling repository, with this emulator as its **verification oracle**. RustyNES is not being ported to FPGA and cannot be; `crates/rustynes-cosim` is the boundary (a narrow C ABI a Verilator testbench links, plus `nes_golden_export`), and the provenance firewall extends to HDL per ADR 0037. **v2.5.0 is scoped to "the 6502 rung closes"**, not a finished core. Excluding the crate from the workspace is the load-bearing detail — cargo unifies features, `irq-timing-trace` selects a *different* per-dot loop in `Bus::tick_one_cpu_cycle`, and the accuracy battery had been validating a scheduler no user runs. It also carries **v2.4.0 "Concordance"**, which merged to `main` and was never tagged: atomic durable writes on every path that persists user data, `Nes::timeline_generation()`, and the 15-anchor release audit. AccuracyCoin **141/141** verified, not asserted. Built on **v2.3.9 "Crucible"** (2026-08-20) — the **gates** release. A crucible tests to destruction rather than inspects, and that is what this release does to the project's own checks: what they cover, what they only *appear* to cover, and where a regression could still reach `main` unchallenged. The v2.3.x line added five tools in four releases, and the recurring finding across all of them was never that the emulation was wrong — it was that **a check reported a pass it had not earned**. **The docs-only CI skip had never worked**: `dorny/paths-filter`’s `predicate-quantifier` defaults to `some`, so the `code` filter’s leading `'**'` matched everything and all seven `!` exclusions under it were dead from the day they were written — a markdown-only PR logged `Filter code = true`. Fixed with **two** filter steps, because the quantifier is step-level and `accuracy` is a list of *alternatives* that becomes unsatisfiable under `every`: the naive one-line fix would have silently disabled the accuracy battery while repairing a different gate. **`test-roms` now runs at review time**, path-filtered over the chip crates, the core, `rustynes-gamedb`, the harness and `tests/` — measured first at 11 of the last 40 merged PRs, so ~72% still pay nothing. **A freeze from one cartridge kept writing into the next** — not a stale label but an active per-frame write into the wrong game, closed by a ROM-transition sweep across every panel under one rule: derived output is discarded, user-authored input is kept, and only input that actively *writes* is neutralised. **The config file is now written atomically and durably** (seven properties, five of them from review rather than the first draft). Plus **257 lines of dead code removed**, the SAFETY-comment rule made a clippy gate (`undocumented_unsafe_blocks`, demonstrated to fail), and two `cargo deny` advisory ignores retired on their own stated condition. `rustynes-apu` and `rustynes-core` both change, so **AccuracyCoin 141/141 (100.00%, RAM decoder) and nestest 0-diff are VERIFIED, not asserted.** **`docs/STATUS.md` is the authoritative current-state record.**
- **Shipped, inside v2.4.1 — v2.4.0 "Concordance".** It merged to `main` and was never tagged, because the workspace version never sat at 2.4.0 on any commit; v2.4.1 carries it. There is deliberately no `v2.4.0` tag. Its scope was: A concordance is an index of where every term actually occurs, and the release is scoped as one: reconcile what the project says about itself with what is true outside it. Four items, each traceable to a recorded deferral rather than newly invented — **(A)** the **owed upstream libretro sync** (`libretro-super` + `libretro/docs`), the one carried obligation with an outside deadline; **(B)** a core-side **timeline generation counter** replacing the last-seen-`cycle()` heuristic for stale telemetry (it covers a restore to a *later* state, which the heuristic cannot), deliberately **not** serialized, so it must land with its consumers and be AccuracyCoin-**verified**; **(C)** a **shared atomic-write helper**, lifting v2.3.9's seven properties out of `config.rs` and giving the Windows tail a real implementation rather than a portable spine; and **(D)** `skip_serializing_if` on `hd_packs` / `shader_presets`, which carry the same false byte-identity claim v2.3.9 corrected in prose only. Explicitly out of scope, and recorded as decisions rather than oversights: the remaining RAM Atlas exports (a cheat is a **write**, so it needs a locked-session predicate the watch export correctly does without), RAM Atlas per-game persistence (a restored verdict without its evidence is a claim that cannot be checked — this panel's whole argument in reverse), APU workstreams **D2 and D4** (unmeasured on purpose; their prior is a null, not an unknown), a CHANGELOG gate (**measured and rejected** — 62% false positives against the project's own history), and any store launch. See [`plans/v2.4.0-concordance-plan.md`](plans/v2.4.0-concordance-plan.md).
-- **Programme after v2.4.0 — the v2.4.1 → v2.5.0 "Fabric" line, and the v2.6–v2.9 programme behind it.** 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 on nestest 0-diff and per-cycle bus equality — 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.4.0 — the v2.4.1 → v2.5.0 "Fabric" line, and the v2.6–v2.9 programme behind it.** 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/`.
- **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"**, the current tag). AccuracyCoin holds **141/141** throughout — but not always *by construction*: v2.3.4, v2.3.7 and v2.3.9 change the core, so for those the number 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"**, the current tag). AccuracyCoin holds **141/141** throughout — but not always *by construction*: v2.3.4, v2.3.7 and v2.3.9 change the core, so for those the number 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.
- **Preceding release:** **RustyNES v2.0.8 "Harbor"** (2026-07-09) — 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`.
diff --git a/to-dos/mister/IMPLEMENTATION_PLAN.md b/to-dos/mister/IMPLEMENTATION_PLAN.md
new file mode 100644
index 00000000..60e12bf6
--- /dev/null
+++ b/to-dos/mister/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,53 @@
+# RustyNES MiSTer core — implementation plan, v2.5.1 → v2.7.0
+
+**Companion to** `to-dos/plans/v2.7.0-mister-core-plan.md` (the narrative plan) and
+`docs/mister.md` (the living spec). This file is the execution view: what is done,
+what is next, and what each release owes.
+
+**Goal:** a functioning, feature-complete RustyNES core for MiSTer FPGA at
+**v2.7.0**, suitable for contributing per
+`ref-docs/2026-08-23-mister-core-contribution-requirements.md`.
+
+## Where the core actually is
+
+| Component | State |
+|---|---|
+| 6502 | **Done.** `rtl/cpu6502.sv`, nine opcode-group ROMs, 2115 records on rung 1, 4537 cycles on the bus gate, 27,388 cycles of nestest |
+| PPU | **Not started** |
+| APU | **Not started** |
+| Cartridge / mappers | **Not started** |
+| SDRAM controller | **Not started** |
+| `sys/` + `emu` integration | **Not started** — `sys/` is an empty placeholder |
+| `.rbf` | **Never produced** |
+
+## Scope, decided 2026-08-23
+
+- **Mappers: the top six** — NROM, MMC1, UxROM, CNROM, MMC3, AxROM (~90% of the
+ licensed library by title count).
+- **Hardware: both boards** — DE10-Nano + the mandatory SDRAM add-on, and a
+ SuperStation One. One `.rbf` must boot both.
+- **Not in this line:** FDS, expansion audio, savestates, Vs. System, NSF, the
+ remaining ~168 mapper families, AccuracyCoin beyond a stated floor.
+
+## 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**. The twenty release slots between v2.5.1 and
+v2.7.0 are **milestones, not dates**.
+
+## Standing rules for every release in this line
+
+1. **A rung may not start until the one below is green in CI.**
+2. **Every new gate is demonstrated to fail by mutation** before it is trusted —
+ three outcomes (CAUGHT / NOT CAUGHT / BUILD-FAILED), baseline captured once
+ into a file the harness refuses to overwrite.
+3. **Every per-rung doc records what the rung cannot verify.** This is the section
+ that keeps the ladder honest; `docs/rung1-6502.md` is the shape to follow.
+4. **The partition between gate and diagnostic is written before the rung
+ starts**, not after a divergence forces the question.
+5. **Quartus re-fit at every rung close**, with `Fmax` recorded — not once at
+ v2.6.5.
+6. **No upstream libretro/RetroArch sync** until the MiSTer core is complete.
+7. **The firewall holds.** `NES_MiSTer` and `fpganes` stay physically outside the
+ workspace. Anything unimplementable from documentation escalates to an ADR
+ **before** any source is opened.
diff --git a/to-dos/mister/SPRINT_PLAN.md b/to-dos/mister/SPRINT_PLAN.md
new file mode 100644
index 00000000..76e5e1b4
--- /dev/null
+++ b/to-dos/mister/SPRINT_PLAN.md
@@ -0,0 +1,41 @@
+# RustyNES MiSTer core — sprint plan
+
+One release per sprint. Each sprint is done when its gate is green **and** the
+per-rung doc records what the rung cannot verify.
+
+| Sprint | Release | Deliverable | Gate |
+|---|---|---|---|
+| M1 | v2.5.1 | ADR 0038 injection API; interrupt sweep | 0 divergences across ~20 hazard opcodes; **both ADR preconditions measured** |
+| M2 | v2.5.2 | PPU register file, VRAM/palette bus, mirroring | Register side effects per cycle |
+| M3 | v2.5.3 | `v`/`t`/`x`/`w`, `$2005`/`$2006` | Mid-frame scroll writes |
+| M4 | v2.5.4 | Background fetch pipeline | Per-dot fetch addresses |
+| M5 | v2.5.5 | Background render → `index_framebuffer` | First frame, popcount 0 |
+| M6 | v2.5.6 | **Sprite evaluation FSM** | Per-dot OAM pattern |
+| M7 | v2.5.7 | Sprite render, priority, sprite-0, overflow | blargg sprite ROMs |
+| M8 | v2.5.8 | VBlank/NMI, odd-frame skip, `$2002` race | **Rung 3 closes**; nestest unbounded |
+| M9 | v2.5.9 | APU pulse + frame counter | `MixRecord` integer levels |
+| M10 | v2.6.0 | APU triangle + noise | Same |
+| M11 | v2.6.1 | APU DMC + DMA stealing | Cycle-exact CPU stall |
+| M12 | v2.6.2 | Frame-counter IRQ; blargg APU battery | **Rung 4 closes**; `cpu_interrupts_v2` |
+| M13 | v2.6.3 | NROM; full system in simulation | First AccuracyCoin run |
+| M14 | v2.6.4 | AccuracyCoin parity | Entry-for-entry. **Rung 5 closes** |
+| M15 | v2.6.5 | `sys/`, `emu`, `hps_io`, video, OSD | **Timing closure**; first `.rbf` |
+| M16 | v2.6.6 | Hardware bring-up, both boards | **One `.rbf` boots both.** Rung 6 closes |
+| M17 | v2.6.7 | SDRAM controller | MMC3's 6 Mb addressable |
+| M18 | v2.6.8 | MMC1, UxROM, CNROM, AxROM | `holy_mapperel` per board |
+| M19 | v2.6.9 | MMC3 | `mmc3_test_2`, `mmc1_a12`. **Rung 7 closes** |
+| M20 | v2.7.0 | Contribution package | Checklist green; submission sent |
+
+## Re-planning triggers
+
+Named now, so a slip is a decision rather than a drift:
+
+- **M6 (sprite evaluation) overruns by more than one sprint.** Expected; it is the
+ hardest item. Split it rather than compressing M7.
+- **M14's AccuracyCoin floor lands below ~90/141.** Stop and diagnose before
+ proceeding to integration — a low score means a PPU or APU defect that rungs 3
+ and 4 did not catch, and that is more valuable to know than a `.rbf`.
+- **M15 fails timing closure.** Re-fit history exists from every prior rung close,
+ so the regression is bisectable. Do not proceed to hardware on a failing fit.
+- **M17's SDRAM controller needs a third-party reference.** ADR **before** any
+ source is opened — no exceptions, and this is the item most likely to test it.
diff --git a/to-dos/mister/TASKS.md b/to-dos/mister/TASKS.md
new file mode 100644
index 00000000..4008ad51
--- /dev/null
+++ b/to-dos/mister/TASKS.md
@@ -0,0 +1,71 @@
+# RustyNES MiSTer core — task board
+
+Legend: `[ ]` open · `[~]` in progress · `[x]` done
+
+## v2.5.1 — the interrupt sweep (rung 2 completes)
+
+- [ ] Implement ADR 0038's injection API behind `cosim-interrupt-inject`
+- [x] **Precondition A:** structural, not benchmarked — `inject_` appears **0**
+ times in the expanded default core and **17** with the feature on, measured
+ with a live control. When the default build emits none of it there is no
+ hot-path quantity left to measure.
+- [x] **Precondition B:** AccuracyCoin **141/141** (RAM decoder) and nestest
+ 0-diff, verified rather than asserted, because `rustynes-core` changed.
+- [x] Sweep: NMI, IRQ **and both together** at every offset across 20
+ instructions — **60 injection points, 0 divergences**.
+- [x] Rung-2 interrupt findings recorded in the sibling's `docs/rung1-6502.md`,
+ with its cannot-verify section. (Kept there rather than in a new
+ `rung2-interrupts.md`: the sweep is rung 2's interrupt half and its
+ findings are inseparable from the rung-1 ROMs they were found against.)
+- [x] Neither precondition failed, so the fallback to option B (waiting for rung
+ 4's `cpu_interrupts_v2`) was not needed.
+
+## v2.5.2 – v2.5.8 — rung 3, the 2C02
+
+- [x] v2.5.2 register file `$2000-$2007`, VRAM/palette bus, mirroring — **12,840 records, 0 divergences**, 8 mutations all caught; also the post-reset masking window, which the plan did not anticipate
+- [ ] v2.5.3 `v`/`t`/`x`/`w` and the `$2005`/`$2006` sequence
+- [ ] v2.5.4 background fetch pipeline (NT/AT/pattern), shift registers
+- [ ] v2.5.5 background rendering into `index_framebuffer`
+- [ ] v2.5.6 **sprite evaluation FSM** — the hardest item
+- [ ] v2.5.7 sprite rendering, priority, sprite-0 hit, overflow
+- [ ] v2.5.8 VBlank/NMI timing, odd-frame skip, `$2002` race — **rung 3 closes**
+- [ ] Write `docs/mister-ppu-rung.md` gate/diagnostic partition **before** v2.5.2
+- [ ] On rung 3 close: re-run nestest **unbounded** and the 5 M-cycle window,
+ which v2.5.0 could not reach
+
+## v2.5.9 – v2.6.2 — rung 4, the 2A03
+
+- [ ] v2.5.9 pulse channels + frame counter
+- [ ] v2.6.0 triangle + noise
+- [ ] v2.6.1 DMC, and its DMA stealing back into the CPU
+- [ ] v2.6.2 frame-counter IRQ edges; blargg APU battery — **rung 4 closes**
+- [ ] Run `cpu_interrupts_v2` — the independent interrupt oracle, now reachable
+
+## v2.6.3 – v2.6.4 — rung 5, NROM + AccuracyCoin
+
+- [ ] v2.6.3 NROM cartridge; first end-to-end AccuracyCoin run
+- [ ] v2.6.4 status vector identical **entry-for-entry**, including `Skipped` and
+ `NotRun` — **rung 5 closes**. State a floor, not a target
+
+## v2.6.5 – v2.6.6 — rung 6, MiSTer integration and hardware
+
+- [ ] v2.6.5 `sys/` verbatim; `emu` module; `hps_io`; `CE_PIXEL` video; `CONF_STR`
+ OSD; `VIDEO_ARX/ARY` at **8:7, set deliberately**; `files.qip`, `.sdc`,
+ `clean.bat`; **Quartus timing closure**; first `.rbf`
+- [ ] v2.6.6 hardware bring-up: DE10-Nano + SDRAM add-on, SuperStation One,
+ **one `.rbf` boots both**; on-device AccuracyCoin — **rung 6 closes**
+
+## v2.6.7 – v2.6.9 — rung 7, memory and mappers
+
+- [ ] v2.6.7 SDRAM controller from spec (ADR first)
+- [ ] v2.6.8 MMC1, UxROM, CNROM, AxROM
+- [ ] v2.6.9 **MMC3** — A12 filtering, IRQ counter — **rung 7 closes**
+
+## v2.7.0 — the contribution package
+
+- [ ] Requirements checklist green (`contribution-checklist.md`)
+- [ ] `releases/RustyNES_YYYYMMDD.rbf`
+- [ ] Unique Home folder chosen
+- [ ] Email `newcores@misterfpga.org`
+- [ ] **Decide deliberately** whether to transfer the repository to MiSTer-devel —
+ acceptance means the repo moves, and that is one-way
diff --git a/to-dos/mister/contribution-checklist.md b/to-dos/mister/contribution-checklist.md
new file mode 100644
index 00000000..2f01a59c
--- /dev/null
+++ b/to-dos/mister/contribution-checklist.md
@@ -0,0 +1,67 @@
+# Contribution checklist — MiSTer FPGA
+
+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.**
+
+Checked at **v2.7.0**, not before. Items marked **(now)** are already settled.
+
+## Repository layout
+
+- [ ] `sys/` present and **verbatim** from `Template_MiSTer` — never modified
+- [x] `rtl/` present **(now)**
+- [x] `releases/` present **(now)**
+- [ ] `.qpf` at the root for the core (one exists for `kitchen_sink` only)
+- [ ] `.qsf`
+- [ ] `.srf`
+- [ ] `.sdc` — timing constraints
+- [ ] Top-level `.sv` implementing the **`emu`** module
+- [ ] `files.qip`
+- [ ] `clean.bat`
+- [x] `.gitignore` **(now)**
+
+## Release artifact
+
+- [ ] `releases/RustyNES_YYYYMMDD.rbf`, named exactly to the convention
+- [ ] Unique **Home folder** chosen (non-arcade requirement)
+- [ ] **No MRA files** — arcade-only, and including them would be wrong
+
+## Licence
+
+- [x] GPL-3.0-or-later **(now)** — and forced upward rather than chosen:
+ `hps_io.sv` is GPL-3.0-or-later and not optional. v2.4.3 audit, 57 files,
+ zero GPL-2.0-only
+
+## Quality bar
+
+- [ ] Core is accurate enough to demonstrate **preservation value**
+- [ ] AccuracyCoin result stated as a **floor**, entry-for-entry, including
+ `Skipped`/`NotRun`
+- [ ] Runs on **real hardware**, both boards, one `.rbf`
+- [ ] **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
+
+## Provenance
+
+- [ ] `docs/provenance.md` states the firewall and that no NES core was ever opened
+- [ ] CI provenance job green — no black-boxed core in the tree
+- [ ] Every RTL file carries its SPDX header
+
+## Submission
+
+- [ ] Email `newcores@misterfpga.org` with the repository link
+- [ ] Await review (the page says days)
+- [ ] **Decide deliberately** on the MiSTer-devel invitation and repository
+ transfer — acceptance moves the repo, it is one-way, and this project owns it
+- [ ] Add to the Cores list with the Home folder
+
+## If declined as a duplicate
+
+Not a failure path — a planned one. See
+`ref-docs/2026-08-23-alternative-fpga-targets.md`.
+
+- [ ] Retro Remake / SuperStation One — already the hardware target
+- [ ] openFPGA / Analogue Pocket — demonstrated MiSTer-core porting path;
+ `nes_top.sv` stays platform-agnostic precisely so this stays cheap
+- [ ] The co-simulation evidence is publishable on its own terms regardless
diff --git a/to-dos/plans/v2.7.0-mister-core-plan.md b/to-dos/plans/v2.7.0-mister-core-plan.md
new file mode 100644
index 00000000..9c966d61
--- /dev/null
+++ b/to-dos/plans/v2.7.0-mister-core-plan.md
@@ -0,0 +1,149 @@
+# v2.5.1 -> v2.7.0 — the RustyNES MiSTer core: PPU, APU, mappers, hardware, and a contributable package
+
+**Supersedes** [`v2.5.0-fabric-plan.md`](v2.5.0-fabric-plan.md), which is
+delivered. That plan built the co-simulation apparatus and closed the 6502. This
+one builds the rest of the console and takes it to a state worth submitting.
+
+**Goal, from the maintainer:** a functioning, feature-complete RustyNES core for
+MiSTer at v2.7.0, suitable for contributing.
+
+## Where the sibling repository actually stands
+
+`RustyNES_MiSTer` today is **one chip and a harness**: `rtl/cpu6502.sv`,
+`rtl/nes_top.sv` (an empty shell), the Quartus subset probe, and a Verilator
+testbench with three gates -- rung 1 (registers at instruction boundaries, nine
+ROMs, **2115 records**), rung 2's bus half (per-cycle `pc` / `bus_addr` /
+`bus_data` / `bus_access`, plus 27,388 cycles of nestest), and rung 2's
+**interrupt sweep** (60 injection points across /IRQ, /NMI and both together).
+`sys/` is empty. There is no PPU, no APU, no mapper, no `.rbf`.
+
+## What v2.5.1 closed, and what it did not
+
+**Closed.** The interrupt sweep, and with it two defects:
+
+- The injection was wired to `Bus::poll_nmi` / `poll_irq`, which the production
+ CPU does not use.
+- A hardware interrupt pushed a return address one byte too high, because
+ `AM_BRK` fell through a shared operand-fetch increment that `BRK` overrode with
+ the same value and an interrupt did not override at all. **`BRK` passing
+ 186/186 is what hid it.**
+
+**Still open, and structurally so.** nestest 0-diff and the 5 M-cycle window both
+stop at a `$2002` read: *both sides address it*, only the data differs, because
+the DUT has no PPU. **Rung 3 unblocks both.** They are not carried as v2.5.1
+debt; they are rung-3 acceptance criteria.
+
+## Decisions taken (maintainer, 2026-08-23)
+
+- **Hardware: both boards eventually.** 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). Verifying **one `.rbf` boots
+ both** turns "SS1 runs MiSTer cores unmodified" from an inherited claim into a
+ measured one.
+- **Mapper scope: the top six** -- NROM, MMC1, UxROM, CNROM, MMC3, AxROM. ~90% of
+ the licensed library by title count. Not FDS, not expansion audio, not the
+ remaining ~168 families.
+- **Schedule: scope v2.7.0 to what genuinely fits**, state the arithmetic up
+ front, and name what moves to v2.8+.
+
+## The arithmetic, stated rather than discovered
+
+Carried from the prior plan's estimates, which have held: **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. Twenty release slots exist
+between v2.5.1 and v2.7.0. **These are milestones, not dates.** Anything that
+does not fit is named at the end rather than discovered at v2.6.7.
+
+## The ladder
+
+Rung order is strict: a rung may not start until the one below is green. Rung 6
+is deliberately **before** rung 7 -- 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.
+
+| Release | Deliverable | Gate |
+|---|---|---|
+| **v2.5.1** | ADR 0038's injection API; the interrupt sweep | **DONE** -- 60 injection points, 0 divergences; both ADR preconditions measured |
+| **v2.5.2** | PPU skeleton: register file `$2000-$2007`, VRAM/palette bus, mirroring | Register read/write side effects match per cycle |
+| **v2.5.3** | `v`/`t`/`x`/`w` scroll registers and address logic | The `$2005`/`$2006` write sequence and mid-frame scroll writes |
+| **v2.5.4** | Background fetch pipeline: NT/AT/pattern, shift registers | Per-dot fetch addresses match the oracle |
+| **v2.5.5** | Background rendering to `index_framebuffer` | First full frame, pre-palette, popcount 0 |
+| **v2.5.6** | Sprite evaluation FSM | Per-dot OAM access pattern -- *the hardest single item in the programme* |
+| **v2.5.7** | Sprite rendering, priority, sprite-0 hit, overflow | The sprite-0 timing ROMs |
+| **v2.5.8** | VBlank/NMI timing, odd-frame skip, `$2002` race | **Rung 3 closes**; nestest 0-diff and the 5 M-cycle window unblock |
+| **v2.5.9** | APU pulse channels + frame counter | `MixRecord` integer levels per CPU cycle |
+| **v2.6.0** | APU triangle + noise | Same |
+| **v2.6.1** | APU DMC, and its DMA stealing back into the CPU | Cycle-exact CPU stall behaviour |
+| **v2.6.2** | Frame-counter IRQ edges; `blargg` APU battery | **Rung 4 closes** |
+| **v2.6.3** | NROM cartridge; full-system integration in simulation | First end-to-end AccuracyCoin run |
+| **v2.6.4** | AccuracyCoin parity to a stated floor | Status vector identical **entry-for-entry**, including `Skipped`/`NotRun`. **Rung 5 closes** |
+| **v2.6.5** | MiSTer integration: `sys/` verbatim, `hps_io` ROM download, video at `CE_PIXEL`, OSD `conf_str` | Quartus 17.0.2 **timing closure**, `.rbf` produced |
+| **v2.6.6** | Hardware bring-up, DE10-Nano + SuperStation One | **One `.rbf` boots both**; on-device AccuracyCoin. **Rung 6 closes** |
+| **v2.6.7** | SDRAM controller written from spec | Read/write timing under the add-on's part; MMC3's 6 Mb addressable |
+| **v2.6.8** | MMC1, UxROM, CNROM, AxROM | `holy_mapperel` per board; commercial-ROM smoke |
+| **v2.6.9** | MMC3 -- A12 filtering, the IRQ counter | `mmc3_test_2`, `mmc1_a12`; **rung 7 closes** |
+| **v2.7.0** | **The contribution package** | Requirements checklist green; submission sent |
+
+## Reference material
+
+Research lands in `ref-docs/` as **dated supplemental files** -- that tree is
+immutable:
+
+- [`2026-08-23-mister-core-contribution-requirements.md`](../../ref-docs/2026-08-23-mister-core-contribution-requirements.md)
+- [`2026-08-23-mister-framework-reference.md`](../../ref-docs/2026-08-23-mister-framework-reference.md)
+- [`2026-08-23-fpga-nes-hardware-source-map.md`](../../ref-docs/2026-08-23-fpga-nes-hardware-source-map.md)
+- [`2026-08-23-alternative-fpga-targets.md`](../../ref-docs/2026-08-23-alternative-fpga-targets.md)
+
+The source map is a **map, not a summary**, and its citations are pinned by
+`crates/rustynes-test-harness/tests/mister_source_map_audit.rs`. Under the
+firewall those pages are the *only* permitted sources, so a dangling citation is
+a behaviour with no source -- discovered exactly when someone is most inclined to
+go looking at a reference core instead.
+
+Execution tracking: [`to-dos/mister/`](../mister/).
+
+## Risks
+
+1. **Sprite evaluation (v2.5.6).** The hardest item. Its per-dot OAM pattern is
+ the classic place FPGA NES implementations diverge, and `ppu-state-trace`
+ **cannot** be the gate -- it encodes RustyNES's FSM, not hardware. *Mitigation:
+ gate on `index_framebuffer` and the sprite-0/overflow ROMs; state trace stays
+ diagnostic.*
+2. **The AccuracyCoin tail (v2.6.4).** Open-ended by nature. *Mitigation: state a
+ **floor**, not a target, and report the vector entry-for-entry so a regression
+ cannot hide behind an unchanged pass count.*
+3. **SDRAM controller from spec (v2.6.7)** under black-box rules. *Mitigation: the
+ part datasheet is public and the interface is documented in `sys/`; escalate to
+ an ADR before reading any third-party controller.*
+4. **Timing closure degrading as the core grows.** *Mitigation: re-fit at every
+ rung close, not once at v2.6.5, and record `Fmax` in each release's notes.*
+5. **Declined as a duplicate.** `NES_MiSTer` scores 121/125 on AccuracyCoin and
+ real Famicom AV hardware also scores ~121/125, so there is no published
+ accuracy headroom. *Mitigation: Retro Remake and openFPGA are planned routes,
+ not contingencies -- hence the alternative-targets research file.*
+6. **The oracle can be wrong.** 141/141 is not "matches silicon". *Mitigation:
+ label every rung by whether it has an **independent** oracle -- blargg and the
+ PPU timing ROMs do; trace fields without a Mesen2 counterpart do not.*
+7. **Black-box discipline under debugging pressure.** Worst at v2.5.6, when the
+ DUT and oracle disagree at one dot. *Mitigation: the CI provenance job stays;
+ `NES_MiSTer` stays physically outside the workspace.*
+
+## Verification bar
+
+The standing project gate, plus, per release:
+
+- **AccuracyCoin 141/141 (RAM decoder)** and nestest 0-diff for RustyNES itself --
+ verified, not asserted, on anything touching a chip crate.
+- Every new gate **demonstrated to fail by mutation** before it is trusted, with
+ three outcomes (CAUGHT / NOT CAUGHT / BUILD-FAILED) and a baseline captured once.
+- Non-zero record counts confirmed -- a filter matching nothing exits 0.
+- `tb/check_rtl_subset.py` clean; Quartus re-fit at every rung close.
+- **No upstream libretro/RetroArch sync**: per the amended cadence it waits for the
+ MiSTer core to be complete. A **licence change still overrides**.
+
+## Explicitly out of scope until v2.8+
+
+Named as decisions, not omissions: **FDS** · **expansion audio**
+(VRC6/VRC7/N163/S5B/MMC5, each its own rung) · **savestates, rewind, cheats** ·
+**Vs. System, NSF, Zapper, Four Score** · **the remaining ~168 mapper families** ·
+**AccuracyCoin beyond the stated floor** · palette options and video filters.