diff --git a/.github/release-notes/v2.4.1.md b/.github/release-notes/v2.4.1.md index 1913fef1..331c7d67 100644 --- a/.github/release-notes/v2.4.1.md +++ b/.github/release-notes/v2.4.1.md @@ -63,11 +63,13 @@ v2.3.9 cleared stale debug telemetry on a ROM change and recorded that it could It is **not** part of the save state, and the reasoning is now on the field with a test pinning it. Serializing it would break two things silently: loading the same slot twice would restore the same generation twice, so a consumer would miss the second load; and a value from another session means nothing in this one. Because it lives outside the snapshot, `snapshot_schema_audit` cannot see it — the property that makes the design correct also means nothing mechanical would notice the reasoning being invalidated. -## Release anchors, and a claim v2.3.9 made that v2.3.9 did not ship +## The release anchors, and the gate that arrived after them `release_anchor_audit.rs` pins **15 anchors across 10 documents** against `[workspace.package] version`, fails closed when a marker matches nothing, and asserts the CHANGELOG header parses the way `release-auto.yml` parses it. -It is described in **v2.3.9's release notes and is not in the v2.3.9 tag.** It merged in #427, after the tag was cut. The audit exists because eight documents had reached six different answers about the current version; that a release note could describe work the release does not contain is the same failure one level up, and it ships here. +It is not in the v2.3.9 tag. v2.3.9 found eight documents holding six different answers about the current version and corrected them **by hand** — which is what its notes describe, and all they claim. The standing gate merged afterwards in #427 and ships here. A hand correction fixes the instance and leaves the mechanism intact; this release is the mechanism. + +**Correcting these notes as first published:** they said v2.3.9's release body *described* the audit and that the tag therefore did not contain what the notes claimed. That is wrong. The v2.3.9 body describes the manual re-synchronisation and nothing more, and that work is in its tag. The claim is retracted here rather than quietly edited out, because it was an accusation against a shipped release and it was unfounded. ## Under the surface diff --git a/AGENTS.md b/AGENTS.md index 07f9899e..9d16e12c 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.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 244 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, which is **described in v2.3.9's published release notes and is not in the v2.3.9 tag** — it merged in #427, after the tag was cut. `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.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 244 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.