diff --git a/.github/release-notes/v2.3.7.md b/.github/release-notes/v2.3.7.md
new file mode 100644
index 00000000..4457abc0
--- /dev/null
+++ b/.github/release-notes/v2.3.7.md
@@ -0,0 +1,68 @@
+RustyNES **v2.3.7 "Overtone"** adds the APU counterpart of Pixel Provenance: point at a moment in the frame and read *why it sounds like that*. An overtone is the structure inside a sound that a single pitch reading throws away, and that is the gap this closes — the Audio Scope already plotted the waveform, the Audio Mixer already set the gains, and nothing at all linked a sample back to the instruction that caused it.
+
+The release's real subject is narrower and less comfortable: **the trap the feature inherited, and the three further places it turned out to be hiding.**
+
+**The accuracy contract is verified, not asserted.** Both `rustynes-apu` and `rustynes-core` change here, so AccuracyCoin was re-run rather than reasoned about: **141/141 (100.00%)** on the authoritative RAM decoder, nestest 0-diff.
+
+## Audio Provenance
+
+Two halves, deliberately shaped as the audio mirror of Pixel Provenance.
+
+**Register attribution** answers *what wrote this register, and from which instruction* — the last write to each of `$4000-$4017` with its value, the CPU cycle, the PC, and whether an instruction or the APU's own reset performed it. Last write, not a history: a ring would need a retention policy nobody has a principled value for, and the Event Viewer already keeps the per-frame write *sequence*. This keeps the per-register *cause*, which it does not.
+
+**The mix trace** answers *what were the channels actually doing* — one record per CPU cycle carrying the mixed sample, the expansion contribution, and all five channels' raw pre-mix outputs. Per CPU cycle rather than per output sample, because that is the cadence at which the mix is genuinely computed; `blip` decimates to 44.1 kHz afterwards, so a per-sample trace would describe the decimator rather than the chip.
+
+Channel values are the **raw** pre-mix outputs, not the values after the frontend's mixer gains. A record scaled by the user's sliders would describe the slider rather than the hardware.
+
+Surfaced at **Tools → Audio → Audio Provenance**. Output-only, runtime-default-off, and not serialized, so the deterministic audio contract is unaffected whether it is armed or not.
+
+## The trap it inherited, closed up front — then found three more times
+
+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. The UI could never observe a populated record, and a comment two lines above the clear asserted the opposite.
+
+Audio Provenance rides the identical rollback. So the carry landed **in the same change as the feature**, not after a bug report — `take_audio_provenance` / `put_audio_provenance` around `restore_quiet` in `RunAhead::finish`, with a control test proving a plain run populates the trace, so a failure of the run-ahead test cannot be misread as a bad assertion.
+
+That would have been the end of it, except the fix's own enumeration was wrong.
+
+### Running the Latency Oracle or the RAM Atlas emptied both panels
+
+`Nes::restore_inner` clears both provenance stores — correct for a genuine timeline change, wrong for a restore that puts back the state the user is still looking at. `rustynes-probe` has **three** such restores, and none of them used the stash:
+
+| Path | How often |
+| --- | --- |
+| `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 |
+| the RAM Atlas panel's `TimelineGuard` | once per observation |
+
+Both stores are **cumulative**. "Which instruction last wrote this" can point thousands of frames back — a palette byte written at level load, a `$4008` linear-counter reload written once during init — so the records were not rebuilt by the next frame. They were gone for the session.
+
+Two things let this through, and neither was carelessness:
+
+1. **The v2.3.6 fix enumerated one caller, not the mechanism.** `RunAhead::finish` was the path the report named; the fix was correct there and stopped there. `docs/pixel-provenance.md` then called run-ahead "the one caller that needs the exception" — a correct rule with an incomplete list under it.
+2. **The test named for the contract could not see the breach.** `measure_in_place_restores_the_live_timeline` compares `nes.snapshot()` before and after, and provenance is deliberately **not** in the snapshot. It asserted something strictly weaker than the contract it is named for, and passed throughout.
+
+Closed by moving the stash into a shared `TrialGuard` — the guard that already carried rewind capture across a trial, for the same underlying reason: state that lives outside the save state is not carried by a snapshot round trip. Four independent mutations pin it, one per store and one per site, so a fix that put back only one store or guarded only one restore fails.
+
+## `$4014` and `$4016` were documented as attributed, and were not
+
+The bus handles both without routing through `Apu::write_register` — `$4014` because OAM DMA is a bus-level burst, `$4016` because it is the controller strobe. So the two writes most worth attributing produced empty slots, under docs that said otherwise. Both are now recorded at the point the bus handles them.
+
+## Two defects found by measurement, not by reading
+
+**The APU throughput bench reshaped the plumbing three times.** `apu_throughput`, built for this release, caught three regressions that were invisible in the diff and that no amount of reading would have found. The bench itself had to be corrected first: it omitted the end-of-cycle pair the bus really performs, which was ~23% of true per-cycle cost.
+
+**Fuzzing the save-state parse boundary found four panics where hand-tracing found one.** The VRC7 OPLL parse was walked by hand, one panic was found and fixed, and a maximally-hostile all-`0xFF` payload was used to confirm. That payload *concealed* a second defect: all-ones set `update_requests` to all-ones, which forced a recompute that hid an `eg_shift` panic. A randomized sweep found three more in minutes. Save states are untrusted input.
+
+## Also fixed
+
+- **VRC7 save states dropped the live FM synthesizer**, so rewind, netplay rollback, and save-state restore all resumed the music from whatever envelope and phase state happened to be held. Banking, IRQ, mirroring and PRG-RAM had always round-tripped correctly.
+- **The browser demo applied no per-game header corrections.** Every mapper, mirroring, and region override the desktop frontend applies from the per-game database was skipped in the wasm build.
+- **Rad Racer's roadside artifact**, where the PPU spliced a hybrid address from a stale `v` rather than the live one.
+- **No CI job carried a timeout**, so a single hung job silently skipped a release for five hours — every job inherited GitHub's six-hour default, and the failure presented as a workflow that had quietly decided not to run.
+
+ That fix covered `ci.yml`, and the remaining gap was found the same way the first one was: by blocking this release. `Clippy Security Lints` hung for over two hours in a setup step during the cut, on a job whose observed runtime is two to three minutes. A sweep found **six** unbounded workflows in total — `security.yml`, `android.yml`, `ios.yml`, `web.yml`, `antigravity-review.yml`, and `release-auto.yml`, the release workflow itself. All are bounded now, and the sweep across `.github/workflows/` comes back empty.
+- A stale comment in `security.yml` justifying a prebuilt-binary install that no longer applied.
+
+## Upgrading
+
+No action required. Audio Provenance is off by default and costs the shipped default nothing when unarmed. Save states, movies, and netplay replays are unchanged in format; the VRC7 fix means a state saved by v2.3.7 carries FM synthesizer state a v2.3.6 state did not, so v2.3.6 will ignore that trailing data rather than fail.
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index c0e8ff97..32283908 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -38,6 +38,7 @@ jobs:
cross-build:
name: NDK cross-build + UniFFI bindings
runs-on: ubuntu-latest
+ timeout-minutes: 45
steps:
- uses: actions/checkout@v7
with:
@@ -100,6 +101,7 @@ jobs:
gradle-bundle:
name: Gradle bundle foss+play release (best-effort packaging)
runs-on: ubuntu-latest
+ timeout-minutes: 45
# Packaging is informational: the cross-build job above is the real link
# gate. R8/Compose packaging pulls the full Android SDK, so a transient
# tooling hiccup here must not fail the merge.
diff --git a/.github/workflows/antigravity-review.yml b/.github/workflows/antigravity-review.yml
index fa3d9d0a..34e7f8a6 100644
--- a/.github/workflows/antigravity-review.yml
+++ b/.github/workflows/antigravity-review.yml
@@ -53,6 +53,10 @@ jobs:
group: agy-review-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
runs-on: [self-hosted, agy]
+ # Bounded harder than the hosted jobs, not softer: this runs on the
+ # maintainer's own hardware, so a hung run holds a real machine rather than
+ # a disposable VM. Observed runtime is 1-3 minutes.
+ timeout-minutes: 30
steps:
# Check out the DEFAULT BRANCH, never the PR head. This job runs the checked-out
# `scripts/agy-review.sh` on a self-hosted runner with a token in the environment,
diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index dabf6d71..76db871a 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -30,6 +30,7 @@ jobs:
testflight:
name: Build xcframework + TestFlight upload
runs-on: macos-latest
+ timeout-minutes: 60
# The unattended SCHEDULED refresh is a clean no-op until signing is
# provisioned: the maintainer sets the `IOS_SIGNING_READY` repo variable to
# "true" once the App Store Connect key + fastlane match secrets exist, so a
diff --git a/.github/workflows/release-auto.yml b/.github/workflows/release-auto.yml
index 3fdef7b0..f8ee0520 100644
--- a/.github/workflows/release-auto.yml
+++ b/.github/workflows/release-auto.yml
@@ -60,6 +60,10 @@ jobs:
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
+ # Bounded like every other job (v2.3.7). `build` below cannot carry one —
+ # `timeout-minutes` is not valid on a job that uses `uses:` — so its budget
+ # lives on the jobs inside `release.yml`.
+ timeout-minutes: 15
outputs:
should_release: ${{ steps.decide.outputs.should_release }}
tag: ${{ steps.decide.outputs.tag }}
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 4aa317fc..9c959904 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -47,10 +47,21 @@ env:
permissions:
contents: read
+# Every job carries an explicit `timeout-minutes`, for the reason PR #400
+# established for `ci.yml` and this file was missed by: without one a job
+# inherits GitHub's SIX-HOUR default, and a hung job then blocks a release while
+# reporting nothing at all. That is not hypothetical here — `Clippy Security
+# Lints` hung for over two hours during the v2.3.7 cut, in a setup step, on a
+# job whose observed runtime is 2-3 minutes, and the release waited on it.
+#
+# Budgets are generous multiples of observed runtime (audit and deny complete in
+# under a minute, clippy in 2-3), because the purpose is to bound a hang, not to
+# police normal variance on a cold cache.
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
@@ -72,6 +83,7 @@ jobs:
deny:
name: Cargo Deny Check
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
@@ -87,6 +99,7 @@ jobs:
clippy-security:
name: Clippy Security Lints
runs-on: ubuntu-latest
+ timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index b7215c44..895fde2d 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -74,6 +74,7 @@ jobs:
build:
name: build demo + docs
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
@@ -175,6 +176,7 @@ jobs:
deploy:
name: deploy to GitHub Pages
+ timeout-minutes: 15
# Only publish from main / manual dispatch — PRs build + size-gate
# but never deploy.
if: github.event_name != 'pull_request'
diff --git a/AGENTS.md b/AGENTS.md
index d078f61b..7ad95f1c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,7 +40,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.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.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.
@@ -202,7 +202,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.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_`, 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/CHANGELOG.md b/CHANGELOG.md
index 0262d704..2a68a346 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,43 @@ cycle-accurate core later replaced.
## [Unreleased]
+## [2.3.7] - 2026-08-19 - "Overtone" (the instruction behind every mixed cycle)
+
+An *overtone* is the structure inside a sound that a single pitch reading throws
+away, and that is what this release adds: the Audio Scope already showed the
+waveform and the Audio Mixer already set the gains, but nothing linked a sample
+back to the instruction that caused it. **Audio Provenance** closes that — 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*, deliberately shaped as the APU counterpart of Pixel Provenance.
+
+The release's real subject, though, is the trap the feature inherited. Pixel
+Provenance shipped **non-functional for four releases** because run-ahead's
+rollback cleared its store before any UI could read it, while a comment two lines
+above the clear asserted the opposite. Audio Provenance rides the identical
+rollback, so the carry landed in the **same change as the feature** rather than
+after a bug report. Then the same defect turned up in **three more places** —
+every restore in `rustynes-probe` — which meant 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 the test named for the contract could
+not see the breach because provenance is deliberately not in the save state.
+
+Two defects were caught by measurement rather than by reading. `apu_throughput`,
+built for this release, reshaped the plumbing **three times** on regressions
+invisible in the diff; and a fuzz sweep of the save-state parse boundary found
+**four** panics in `VRC7`'s OPLL where hand-tracing had found one — the
+maximally-hostile fixed payload concealed one of them.
+
+Also fixed: `$4014` and `$4016` were documented as attributed and were not, since
+the bus handles them without routing through `Apu::write_register`; the browser
+demo applied **no** per-game header corrections; *Rad Racer*'s roadside artifact,
+where the PPU spliced a hybrid address from a stale `v`; VRC7 save states dropped
+the live FM synthesizer, so rewind garbled the music; and no CI job carried a
+timeout, so 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.**
+
### Added
- **Audio provenance — point at a moment in the frame and read why it sounds
@@ -339,6 +376,22 @@ cycle-accurate core later replaced.
mean anything. It was the second hung job that night; the first cost two hours
on a PR.
+ **That fix covered `ci.yml` only, and the gap was found the way the first one
+ was — by being blocked.** During this release's own cut, `Clippy Security
+ Lints` hung for over two hours in a setup step, on a job whose observed runtime
+ is two to three minutes, holding the release PR. `security.yml` had no
+ `timeout-minutes` on any of its three jobs, and a sweep found five more
+ unbounded workflows: `android.yml`, `ios.yml`, `web.yml`,
+ `antigravity-review.yml`, and `release-auto.yml` — the release workflow itself.
+ All are now bounded, so the sweep across `.github/workflows/` comes back empty.
+
+ Two details worth keeping. `release-auto.yml`'s `build` job **cannot** carry a
+ timeout, because `timeout-minutes` is not valid on a job that uses `uses:`; its
+ budget lives on the jobs inside `release.yml`, which already had them. And
+ `antigravity-review.yml` is bounded *harder* than the hosted jobs rather than
+ softer, because it runs on the maintainer's own hardware, where a hung run
+ holds a real machine instead of a disposable VM.
+
## [2.3.6] - 2026-08-17 - "Sounding" (measuring, and what a measurement may claim)
A *sounding* is a depth measured with its uncertainty attached, and that is what
diff --git a/Cargo.lock b/Cargo.lock
index 99227d58..11f11e46 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4290,7 +4290,7 @@ dependencies = [
[[package]]
name = "rustynes-android"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"android-activity",
"android_logger",
@@ -4308,7 +4308,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "rustynes-cheevos"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"cc",
"ureq",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4346,7 +4346,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "rustynes-frontend"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"anstyle",
"arboard",
@@ -4416,18 +4416,18 @@ dependencies = [
[[package]]
name = "rustynes-gamedb"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"rustynes-core",
]
[[package]]
name = "rustynes-gfx-shaders"
-version = "2.3.6"
+version = "2.3.7"
[[package]]
name = "rustynes-hdpack"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"lewton",
"png",
@@ -4438,7 +4438,7 @@ dependencies = [
[[package]]
name = "rustynes-ios"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"bytemuck",
"cpal",
@@ -4452,7 +4452,7 @@ dependencies = [
[[package]]
name = "rustynes-libretro"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"libc",
"rust-libretro",
@@ -4461,7 +4461,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4473,7 +4473,7 @@ dependencies = [
[[package]]
name = "rustynes-mobile"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"rustynes-core",
"rustynes-hdpack",
@@ -4488,7 +4488,7 @@ dependencies = [
[[package]]
name = "rustynes-netplay"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"futures-util",
"js-sys",
@@ -4504,7 +4504,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"bitflags 2.13.1",
"criterion",
@@ -4516,21 +4516,21 @@ dependencies = [
[[package]]
name = "rustynes-probe"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"rustynes-core",
]
[[package]]
name = "rustynes-ra"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"rustynes-cheevos",
]
[[package]]
name = "rustynes-script"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"mlua",
"piccolo",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "rustynes-test-harness"
-version = "2.3.6"
+version = "2.3.7"
dependencies = [
"insta",
"png",
diff --git a/Cargo.toml b/Cargo.toml
index 3976366f..7b86ef93 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -47,7 +47,7 @@ default-members = ["crates/rustynes-libretro"]
# So: bump this as part of the release cut, together with the CHANGELOG's
# `[Unreleased]` -> `[X.Y.Z]` rename and `rustynes_libretro.info`'s
# `display_version`, and not before.
-version = "2.3.6"
+version = "2.3.7"
edition = "2024"
rust-version = "1.96"
license = "GPL-3.0-or-later"
diff --git a/README.md b/README.md
index 561f81af..a88145ac 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@