diff --git a/AGENTS.md b/AGENTS.md index 8b982567..afad84af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ 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.2.6 "Almanac"** (2026-08-04) — a **de-monetization + provenance** release opening the **v2.2.6 → v2.3.0** NESdev-remediation line. **RustyNES is permanently open-source and income-free (ADR 0035)**: all planned monetization is removed (the `rustynes-monetization` crate, `docs/monetization/`, and the Android/iOS billing / ad / freemium / paywall layers deleted) and the native apps are kept as **free FOSS apps** (no ads, no tracking, no paid unlock; the free Google-Play services + `foss`/`play` split retained). It also discloses (ADR 0030) that the PPU hybrid-address *timing* was calibrated to TriCNES (reproducing the Rad Racer mis-render), flagged for a documentation-derived rework in v2.3.0. **Zero emulation-core behavior changes**, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. On top of **v2.2.5 "Colophon"** (2026-08-03) — a **provenance, licensing, and documentation-integrity** release with **zero emulation-core behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction). It reworded in-source comments that had mischaracterized publicly-documented hardware-behavior implementations as "ports of" copyleft emulators (Mesen2 GPLv3, puNES GPLv2) into the accurate oracle framing; rewrote `NOTICE` to disclose the behavioral-oracle use of GPL emulators (Mesen2/MesenCE, higan, GeraNES, ares, FCEUX, Nestopia, puNES — no code incorporated) and to attribute the genuinely-incorporated permissive components (emu2413, TriCNES, rcheevos — all MIT), the bundled fonts and test ROMs, and the CRT-shader/NTSC-filter visual influences as independent reimplementations; disclosed **GeraNES (GPL-3.0-only)** as an oracle; added `docs/originality-and-provenance.md`; and added an AI-assistance disclosure to the README (removing a misleading comparison graphic and fixing a mislabeled screenshot caption). On top of **v2.2.4 "Cartridge"** (2026-07-24) — a **libretro / RetroArch distribution** cut whose purpose is that the RustyNES core **builds and installs cleanly through the Libretro buildbot** () for in-RetroArch use. **Zero emulation-core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical to v2.2.3, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. `crates/rustynes-libretro` wraps `rustynes-core`, so it inherits every v2.2.3 change automatically (the fast-dot-path default; the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema, transparent because `get_serialize_size` / `on_serialize` size and emit the *current* snapshot via `Nes::snapshot_core_into` rather than a fixed layout; the `Mapper::mix_audio` i32 widening; the Zapper model; the `mNNN_` mapper rename), and both buildbot cross-ABIs the CI early-warning gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — `cargo check --release -p rustynes-libretro` clean. The concrete change is a **`rustynes_libretro.info` metadata correction**: **`disk_control` `false` → `true`** (the real fix — the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed but was advertised as absent, hiding multi-disk FDS swapping from RetroArch's Quick Menu), `display_version` `v1.0.0` → `v2.2.4`, and the description mapper count `168` → `172`. Libretro **core options** (region / overscan / palette / accuracy toggles) remain unexposed — `core_options = "false"` is accurate, a documented future enhancement rather than a v2.2.4 gap. The Antigravity PR reviewer standardization onto the shared template rides along. On top of **v2.2.3 "Datum"** (2026-07-23) — a **performance and accuracy-closure patch**, the product of a measure-first appraisal that profiled the emulator and acted on what the profile showed rather than on intuition. **Performance:** the specialized PPU fast dot path is promoted to the **default** and exposed to users for the first time — `Nes::set_fast_dotloop` had **no caller outside the core**, so a **−11.3%** frame-time win (fresh clean-host Criterion, reproducing v2.1.8's +12.3% by a different method; differential-tested bit-identical every frame since v2.1.8) shipped switched off and unreachable; release builds now ship **PGO-optimized** Linux binaries when the existing >3%-and-byte-identical gate passes; and CI gained a same-runner **relative** frame-time regression gate, closing a hole where a 2.5x slowdown passed the deliberately-loose absolute ceiling. **Two optimizations were measured and REJECTED** and are documented with their numbers per `docs/performance.md`'s convention — P3 (`emit_pixel` bounds-check elision) made the shipped default *slower* (+4.32% / +3.35% on the `_fast` workloads, p ≤ 0.02), and P4 (`cpu_clock`) found both textbook optimizations already implemented with the one remaining lever capped at **≤1.9%**. **Accuracy:** the **last two Holy Mapperel residuals are closed**, so all 17 ROMs report `detail=0000` (was 15/17) — MMC1's two software WRAM write-protect layers (`$E000` bit 4 + SNROM's CHR-register layer, gated on `chr_is_ram`) and FME-7's open bus on the RAM-selected-but-disabled window, both routed through the trait's existing `cpu_read_unmapped` contract. MMC1 is the change Holy Mapperel's README calls a game-compatibility hazard (FCEUX / PowerPak omit it), so it was validated before landing: **60/60** commercial ROMs including seven battery-backed MMC1 saves, plus **138/138** extended. The **Sunsoft 5B absolute level** is calibrated against Mesen2, which required widening `Mapper::mix_audio` to `i32` (the correct full-scale 5B tone `1882 * 18.471 = 34,761` does not fit `i16`). A **save-state schema gap** is fixed — `PPU_SNAPSHOT_VERSION` **8** carrying the sprite-eval FSM + OAM data-bus state, plus an APU **v4** tail — which is what made AccuracyCoin report **141/141 through run-ahead** as well as without it; a new standing field-vs-schema audit found it and the two APU gaps mechanically. A **Zapper beam-relative light model** lands opt-in / default-off (no pass-fail light-gun ROM exists to adjudicate it). **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff. Also: the eleven `sprintN.rs` mapper modules (27,631 lines, ~110 boards) are renamed for the boards they emulate with `mNNN_` mapper-number prefixes, proven content-preserving by a byte-for-byte item comparison (930 items, 0 altered) and an identical 172-ID dispatch table. +**Current release: v2.2.7 "Timbre II"** (2026-08-04) — an **expansion-audio fidelity** release (2nd of the v2.2.6 → v2.3.0 NESdev-remediation line), driven by a measure-first cross-reference of VRC6 and Sunsoft 5B against 11 reference emulators + the NESdev wiki (Mesen2-only comparison hides where Mesen2 is the outlier). **VRC6 recalibrated to ~1.0× a 2A03 pulse** (`VRC6_MIX_SCALE` 979 → 650; the NESdev/field consensus — rustico/tetanes/BizHawk encode 1.0× exactly; Mesen2's louder ~1.506× mixer weighting was the outlier a reviewer flagged; `db_vrc6a/b` oracle 1.506 → 1.0), and the **Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC** (`SUNSOFT5B_LOG_VOL32`, matching nestopia/rustico, replacing the 4-bit 3 dB approximation). **Expansion-only — base 2A03 byte-identical**, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff; the base BLEP is a verified 81.6 dB-SFDR band-limited decimator. Built on **v2.2.6 "Almanac"** (2026-08-04) — a **de-monetization + provenance** release opening the **v2.2.6 → v2.3.0** NESdev-remediation line. **RustyNES is permanently open-source and income-free (ADR 0035)**: all planned monetization is removed (the `rustynes-monetization` crate, `docs/monetization/`, and the Android/iOS billing / ad / freemium / paywall layers deleted) and the native apps are kept as **free FOSS apps** (no ads, no tracking, no paid unlock; the free Google-Play services + `foss`/`play` split retained). It also discloses (ADR 0030) that the PPU hybrid-address *timing* was calibrated to TriCNES (reproducing the Rad Racer mis-render), flagged for a documentation-derived rework in v2.3.0. **Zero emulation-core behavior changes**, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. On top of **v2.2.5 "Colophon"** (2026-08-03) — a **provenance, licensing, and documentation-integrity** release with **zero emulation-core behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction). It reworded in-source comments that had mischaracterized publicly-documented hardware-behavior implementations as "ports of" copyleft emulators (Mesen2 GPLv3, puNES GPLv2) into the accurate oracle framing; rewrote `NOTICE` to disclose the behavioral-oracle use of GPL emulators (Mesen2/MesenCE, higan, GeraNES, ares, FCEUX, Nestopia, puNES — no code incorporated) and to attribute the genuinely-incorporated permissive components (emu2413, TriCNES, rcheevos — all MIT), the bundled fonts and test ROMs, and the CRT-shader/NTSC-filter visual influences as independent reimplementations; disclosed **GeraNES (GPL-3.0-only)** as an oracle; added `docs/originality-and-provenance.md`; and added an AI-assistance disclosure to the README (removing a misleading comparison graphic and fixing a mislabeled screenshot caption). On top of **v2.2.4 "Cartridge"** (2026-07-24) — a **libretro / RetroArch distribution** cut whose purpose is that the RustyNES core **builds and installs cleanly through the Libretro buildbot** () for in-RetroArch use. **Zero emulation-core changes** — the deterministic `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical to v2.2.3, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff by construction. `crates/rustynes-libretro` wraps `rustynes-core`, so it inherits every v2.2.3 change automatically (the fast-dot-path default; the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema, transparent because `get_serialize_size` / `on_serialize` size and emit the *current* snapshot via `Nes::snapshot_core_into` rather than a fixed layout; the `Mapper::mix_audio` i32 widening; the Zapper model; the `mNNN_` mapper rename), and both buildbot cross-ABIs the CI early-warning gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — `cargo check --release -p rustynes-libretro` clean. The concrete change is a **`rustynes_libretro.info` metadata correction**: **`disk_control` `false` → `true`** (the real fix — the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed but was advertised as absent, hiding multi-disk FDS swapping from RetroArch's Quick Menu), `display_version` `v1.0.0` → `v2.2.4`, and the description mapper count `168` → `172`. Libretro **core options** (region / overscan / palette / accuracy toggles) remain unexposed — `core_options = "false"` is accurate, a documented future enhancement rather than a v2.2.4 gap. The Antigravity PR reviewer standardization onto the shared template rides along. On top of **v2.2.3 "Datum"** (2026-07-23) — a **performance and accuracy-closure patch**, the product of a measure-first appraisal that profiled the emulator and acted on what the profile showed rather than on intuition. **Performance:** the specialized PPU fast dot path is promoted to the **default** and exposed to users for the first time — `Nes::set_fast_dotloop` had **no caller outside the core**, so a **−11.3%** frame-time win (fresh clean-host Criterion, reproducing v2.1.8's +12.3% by a different method; differential-tested bit-identical every frame since v2.1.8) shipped switched off and unreachable; release builds now ship **PGO-optimized** Linux binaries when the existing >3%-and-byte-identical gate passes; and CI gained a same-runner **relative** frame-time regression gate, closing a hole where a 2.5x slowdown passed the deliberately-loose absolute ceiling. **Two optimizations were measured and REJECTED** and are documented with their numbers per `docs/performance.md`'s convention — P3 (`emit_pixel` bounds-check elision) made the shipped default *slower* (+4.32% / +3.35% on the `_fast` workloads, p ≤ 0.02), and P4 (`cpu_clock`) found both textbook optimizations already implemented with the one remaining lever capped at **≤1.9%**. **Accuracy:** the **last two Holy Mapperel residuals are closed**, so all 17 ROMs report `detail=0000` (was 15/17) — MMC1's two software WRAM write-protect layers (`$E000` bit 4 + SNROM's CHR-register layer, gated on `chr_is_ram`) and FME-7's open bus on the RAM-selected-but-disabled window, both routed through the trait's existing `cpu_read_unmapped` contract. MMC1 is the change Holy Mapperel's README calls a game-compatibility hazard (FCEUX / PowerPak omit it), so it was validated before landing: **60/60** commercial ROMs including seven battery-backed MMC1 saves, plus **138/138** extended. The **Sunsoft 5B absolute level** is calibrated against Mesen2, which required widening `Mapper::mix_audio` to `i32` (the correct full-scale 5B tone `1882 * 18.471 = 34,761` does not fit `i16`). A **save-state schema gap** is fixed — `PPU_SNAPSHOT_VERSION` **8** carrying the sprite-eval FSM + OAM data-bus state, plus an APU **v4** tail — which is what made AccuracyCoin report **141/141 through run-ahead** as well as without it; a new standing field-vs-schema audit found it and the two APU gaps mechanically. A **Zapper beam-relative light model** lands opt-in / default-off (no pass-fail light-gun ROM exists to adjudicate it). **AccuracyCoin holds 141/141 (100.00%)**, nestest 0-diff. Also: the eleven `sprintN.rs` mapper modules (27,631 lines, ~110 boards) are renamed for the boards they emulate with `mNNN_` mapper-number prefixes, proven content-preserving by a byte-for-byte item comparison (930 items, 0 altered) and an identical 172-ID dispatch table. The prior release, **v2.2.2 "Conduit"** (2026-07-21), was a **build, distribution, and CI-integrity patch**: the **libretro buildbot recipe from 1 of 10 jobs green to all ten building** (the last step before RustyNES lands in RetroArch's built-in core downloader), a **GitHub Actions supply-chain hardening** pass (`persist-credentials: false` on all 19 checkouts, a fail-closed release-tag check via `git/matching-refs`, `dtolnay/rust-toolchain` SHA-pinned off `@master`), and the toolchain **collapsed to one pinned source of truth** — no toolchain version literal anywhere under `.github/` and **no `nightly` on any build path**. **Zero emulation-core changes**, so AccuracyCoin held 141/141 by construction. Its one behavioural improvement in a shipped artifact: the libretro **tvOS** core built with `panic = "abort"` like every other platform. @@ -185,7 +185,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.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.6 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio, video/gamma, TAS/UX, and the PPU left-edge + hybrid-address accuracy capstone at **v2.3.0** "Datum II") is in progress. The freed **v2.3.0** slot is repurposed as that accuracy capstone (NOT a store launch — RustyNES is now income-free per ADR 0035; any free mobile-app store listing is a later, unversioned step with no monetization — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding. +- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v2.2.7 "Timbre II"** (2026-08-04, an expansion-audio fidelity release — VRC6 recalibrated to ~1.0× a 2A03 pulse per the NESdev/field consensus [`VRC6_MIX_SCALE` 979→650; Mesen2's ~1.5× was the loud outlier], and the Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC; expansion-only, so the base 2A03 is byte-identical and AccuracyCoin holds 141/141), on top of **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.7 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio [v2.2.7, shipped], video/gamma, TAS/UX, and the PPU left-edge + hybrid-address accuracy capstone at **v2.3.0** "Datum II") is in progress. The freed **v2.3.0** slot is repurposed as that accuracy capstone (NOT a store launch — RustyNES is now income-free per ADR 0035; any free mobile-app store listing is a later, unversioned step with no monetization — see `to-dos/ROADMAP.md`). Two distinct "v2.0"s exist and must not be conflated, **both now shipped, at different times, for different reasons**: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) — it was the *only* scheduler through v1.10.0. RustyNES's own **v2.0.0 "Timebase"** release (2026-07-03) is a *different* milestone that *replaces* that same dot-lockstep scheduler outright: the **one-clock + every-cycle-bus-access collapse** (a single canonical cycle counter + a split-around-the-access `start_cycle`/`end_cycle` PPU catch-up, mirroring Mesen2's structure), full Vs. `DualSystem` dual-console emulation (core-and-harness-only; frontend wiring deferred), and the breaking save-state / cross-version changes it entailed (ADR 0002 / ADR 0028 / ADR 0029) — the one release that broke byte-identity / save-state compatibility, by design. The R1/R2 hard-tier MMC3 IRQ-timing residual was investigated under a bounded-effort campaign and is by-design-deferred beyond v2.0.0, not closed — see ADR 0002's decision-update section for the mechanism-level finding. - **Forward plans + roadmap live in `to-dos/`.** `to-dos/ROADMAP.md` (updated in #129) is the planning entry point and frames the release line + "the path to v2.0.0 and beyond"; `to-dos/plans/` holds the per-release plan docs (through `v1.7.0-forge-plan.md` on `main`, plus the staged-forward `v1.8.0-android-plan.md` / `v1.9.0-ios-plan.md` / `v2.0.0-master-clock-plan.md`) + the `to-dos/plans/engine-lineage/` history archive + a `to-dos/plans/research/` reference-mining archive. - The v1.0.0 release + GitHub Pages/CI + post-release record is in `docs/v1.0.0-synthesis-handoff-2026-06-13.md` — read it before touching CI, Pages, or release tooling. Full per-release history is in `CHANGELOG.md`. - **Markdownlint is a CI gate** (pre-commit, pinned `markdownlint-cli v0.39.0`). The local `markdownlint` binary is a newer version that reports rules v0.39.0 lacks (e.g. MD060) — those are NOT gated; verify with `pre-commit run markdownlint --all-files`, not the bare binary. `.markdownlint.json` keeps `MD013`/`MD033`/`MD041` disabled by design (long technical tables, the README HTML banner/``, the HTML-led README). `.markdownlintignore` exempts `ref-docs/`, `ref-proj/`, the 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 2346c0ac..4b47bca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,38 @@ cycle-accurate core later replaced. ## [Unreleased] +## [2.2.7] - 2026-08-04 - "Timbre II" (expansion-audio fidelity: VRC6 + Sunsoft 5B) + +An **expansion-audio accuracy** release addressing NESdev-forum feedback. Driven by a +measure-first cross-reference of VRC6 and Sunsoft 5B against **11 reference emulators** +(Mesen2/MesenCE, ares, higan, nestopia, fceux, tetanes, rustico, GeraNES, puNES, BizHawk) +plus the NESdev wiki — because a Mesen2-only comparison hides where Mesen2 itself is the +outlier. **The base 2A03 output is byte-identical** (these are expansion-only changes: +`mix_audio()==0` for non-expansion mappers), so **AccuracyCoin holds 141/141 (100.00%)**, +nestest is 0-diff, and blargg/kevtris are unchanged. The base BLEP decimator was +independently verified excellent (SFDR **81.6 dB**, `rustynes-apu` spectral test). + +### Changed + +- **VRC6 level recalibrated to the field/hardware consensus** — a full-volume VRC6 pulse + is now **≈1.0×** a 2A03 pulse (was ~1.506×). `VRC6_MIX_SCALE` 979 → 650. The prior + 1.506× mirrored **Mesen2's specifically louder mixer convention** (Mesen2 weights VRC6 + `×5`); a reviewer flagged VRC6 as too loud, and the cross-reference confirmed Mesen2 is + the loud outlier: the NESdev wiki says the VRC6 pulses are "roughly equivalent to the + pulse channels of the 2A03", and rustico / tetanes / BizHawk encode a VRC6 pulse == a + 2A03 pulse *exactly* (ares/higan/nestopia reach the same via `sum/61`). The `db_vrc6a/b` + oracle target moved 1.506 → 1.000 and the two snapshots were re-blessed (audio-only — + framebuffer + cycle count byte-identical). VRC6's per-channel balance (linear + `pulse+pulse+saw`, saw 0–31 vs pulse 0–15) was already correct and is unchanged. +- **Sunsoft 5B envelope now uses the exact 5-bit 1.5 dB/step DAC** — the envelope-mode + amplitude path indexes a new 32-level `SUNSOFT5B_LOG_VOL32` table (×1.1885/step = +1.5 dB, + matching nestopia/rustico) at full 5-bit resolution, instead of truncating the envelope + to 4-bit (the wiki-named 3 dB approximation). Fixed 4-bit volume tones (already correct + 3 dB/step) and the 5B absolute level (1.265×) are unchanged; the odd entries of the + 32-level table equal the 4-bit table exactly (guarded by a new unit test). Extant 5B + test-ROM snapshots stay byte-identical; envelope-modulated 5B music now gets the exact + curve. + ## [2.2.6] - 2026-08-04 - "Almanac" (de-monetization + provenance accuracy) A **de-monetization and provenance** release. RustyNES is now permanently diff --git a/Cargo.lock b/Cargo.lock index 54adf122..9d1253cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "2.2.6" +version = "2.2.7" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "2.2.6" +version = "2.2.7" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "2.2.6" +version = "2.2.7" dependencies = [ "cc", "ureq", @@ -4329,7 +4329,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "2.2.6" +version = "2.2.7" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4346,7 +4346,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "2.2.6" +version = "2.2.7" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4357,7 +4357,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "2.2.6" +version = "2.2.7" dependencies = [ "anstyle", "arboard", @@ -4411,11 +4411,11 @@ dependencies = [ [[package]] name = "rustynes-gfx-shaders" -version = "2.2.6" +version = "2.2.7" [[package]] name = "rustynes-hdpack" -version = "2.2.6" +version = "2.2.7" dependencies = [ "lewton", "png", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "2.2.6" +version = "2.2.7" dependencies = [ "bytemuck", "cpal", @@ -4440,7 +4440,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "2.2.6" +version = "2.2.7" dependencies = [ "libc", "rust-libretro", @@ -4449,7 +4449,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "2.2.6" +version = "2.2.7" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4461,7 +4461,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "2.2.6" +version = "2.2.7" dependencies = [ "rustynes-core", "rustynes-hdpack", @@ -4476,7 +4476,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "2.2.6" +version = "2.2.7" dependencies = [ "futures-util", "js-sys", @@ -4492,7 +4492,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "2.2.6" +version = "2.2.7" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4504,14 +4504,14 @@ dependencies = [ [[package]] name = "rustynes-ra" -version = "2.2.6" +version = "2.2.7" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "2.2.6" +version = "2.2.7" dependencies = [ "mlua", "piccolo", @@ -4522,7 +4522,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "2.2.6" +version = "2.2.7" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index 64370a8f..9e66b87b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ members = [ default-members = ["crates/rustynes-libretro"] [workspace.package] -version = "2.2.6" +version = "2.2.7" edition = "2024" rust-version = "1.96" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 0f4fad56..96aaf1de 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Build Status License: MIT OR Apache-2.0 Version Rust: 1.96
+ Build Status License: MIT OR Apache-2.0 Version Rust: 1.96
AccuracyCoin nestest Try in browser
Platform

@@ -795,9 +795,14 @@ and the Material-for-MkDocs documentation handbook at ## Current Release -RustyNES's current release is **v2.2.6 "Almanac"**, a **de-monetization and -provenance** release that opens the **v2.2.6 → v2.3.0** line addressing NESdev-forum -feedback. It carries **zero emulation-core behavior changes** — so **AccuracyCoin holds +RustyNES's current release is **v2.2.7 "Timbre II"**, an **expansion-audio fidelity** +release: VRC6 recalibrated to the field/NESdev consensus (a VRC6 pulse ≈ a 2A03 pulse, +~1.0× — Mesen2 was the loud outlier), and the Sunsoft 5B envelope moved to the exact 5-bit +1.5 dB/step DAC (matching nestopia/rustico). Expansion-only, so the base 2A03 output is +byte-identical (**AccuracyCoin 141/141**, nestest 0-diff); the base BLEP is a verified +81.6 dB-SFDR band-limited decimator. It builds on **v2.2.6 "Almanac"**, the +**de-monetization and provenance** release that opened the **v2.2.6 → v2.3.0** line +addressing NESdev-forum feedback. **v2.2.6** carried **zero emulation-core behavior changes** — so **AccuracyCoin holds 141/141 (100.00%)**, nestest is 0-diff, and the `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical by construction. What it does: @@ -1037,7 +1042,7 @@ If you use RustyNES in academic research, please cite: author = {RustyNES Contributors}, title = {RustyNES: A Cycle-Accurate NES Emulator in Rust}, year = {2026}, - version = {2.2.6}, + version = {2.2.7}, url = {https://github.com/doublegate/RustyNES}, note = {Cycle-accurate NES emulator on a master-clock-precise scheduler; AccuracyCoin 100\% (141/141), nestest 0-diff; 172 mapper families, diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 28436656..0c24b1b3 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1569,7 +1569,7 @@ mod expansion_level_tripwire { #[test] fn expansion_audio_levels_are_pinned() { - assert_eq!(crate::m024_vrc6::VRC6_MIX_SCALE, 979, "VRC6: {RE_BLESS}"); + assert_eq!(crate::m024_vrc6::VRC6_MIX_SCALE, 650, "VRC6: {RE_BLESS}"); assert_eq!( crate::m005_mmc5::MMC5_PULSE_SCALE, 650, diff --git a/crates/rustynes-mappers/src/m024_vrc6.rs b/crates/rustynes-mappers/src/m024_vrc6.rs index d12d31db..7962f38e 100644 --- a/crates/rustynes-mappers/src/m024_vrc6.rs +++ b/crates/rustynes-mappers/src/m024_vrc6.rs @@ -54,24 +54,34 @@ fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize { /// Linear scale applied to the summed VRC6 channel output (see /// [`Vrc6::mix_audio`]). /// -/// Calibrated so a single full-volume (15) VRC6 pulse reaches ~1.5x the -/// amplitude of a single full-volume 2A03 pulse — the level the bbbradsmith -/// `db_vrc6` decibel-comparison ROM and the Mesen2 mixer characterize (Mesen2 -/// `NesSoundMixer::GetOutputVolume` weights VRC6 at `output * 5` against a -/// 2A03 pulse DAC of `95.88*5000/(8128/15+100) ≈ 746.9`, giving `15*15*5 / -/// 746.9 ≈ 1.506`). Concretely, one pulse toggling 0↔15 swings the mixer by -/// `15 * 979 = 14685` raw units; divided by the bus's `/65536` external-audio -/// normalization that is `0.2241`, versus the 2A03 pulse's `pulse_table[15] ≈ -/// 0.1488` — a ratio of `1.506`. The full three-channel peak stays in range: -/// `(61 - 30) * 979 = 30349 < i16::MAX`, so a loud Akumajou-Densetsu / Madara -/// passage never clips. Before v2.1.6 this was `256` (≈0.39x the 2A03 pulse — -/// ~11.7 dB too quiet). See `docs/apu-2a03.md` §Expansion-audio levels. +/// Calibrated (v2.2.7 "Timbre II") so a single full-volume (15) VRC6 pulse +/// reaches ~**1.0x** the amplitude of a single full-volume 2A03 pulse — the +/// level the NESdev wiki ("at maximum volume, the pulse channels of the VRC6 +/// are roughly *equivalent* to the pulse channels of the 2A03"), the bbbradsmith +/// `db_vrc6` decibel-comparison ROM's matched-level intent, and the wider +/// reference field all corroborate: rustico, tetanes, and BizHawk each encode a +/// VRC6 pulse == a 2A03 pulse *exactly*, and ares/higan/nestopia reach the same +/// target via a `sum/61` normalization. Concretely, one pulse toggling 0↔15 +/// swings the mixer by `15 * 650 = 9750` raw units; divided by the bus's +/// `/65536` external-audio normalization that is `0.1488`, matching the 2A03 +/// pulse's `pulse_table[15] ≈ 0.1488` — a ratio of `1.00`. The full +/// three-channel peak stays in range: `(61 - 30) * 650 = 20150 < i16::MAX`, so a +/// loud Akumajou-Densetsu / Madara passage never clips. +/// +/// **History:** before v2.2.7 this was `979` (~1.506x the 2A03 pulse), which +/// mirrored Mesen2's specifically *louder* mixer convention (Mesen2 weights VRC6 +/// at `output * 5` in `NesSoundMixer::GetOutputVolume`). A NESdev-forum reviewer +/// flagged the VRC6 balance as too loud; a cross-check against the whole +/// `ref-proj/` field (see the cross-reference in the v2.2.7 notes) confirmed +/// Mesen2 is the loud outlier and the field/hardware consensus is 1.0x. Before +/// v2.1.6 it was `256` (≈0.39x — ~11.7 dB too quiet). See `docs/apu-2a03.md` +/// §Expansion-audio levels. /// /// `pub(crate)` so the NSF-playback path (`crate::nsf_expansion::Vrc6Exp::mix`) /// references the SAME constant as the cartridge path — the two mixers can /// never drift apart, guaranteeing an NSF VRC6 tune stays level-matched to a /// VRC6 cartridge. -pub(crate) const VRC6_MIX_SCALE: i16 = 979; +pub(crate) const VRC6_MIX_SCALE: i16 = 650; /// VRC6 audio pulse channel state (`$9000-$9002` for pulse 1, `$A000-$A002` /// for pulse 2). Period is 12-bit, decrements every CPU cycle. On @@ -548,11 +558,12 @@ impl Mapper for Vrc6 { // Three channels: pulse1 (4-bit, 0..=15), pulse2 (4-bit, 0..=15), // sawtooth (5-bit, 0..=31). Sum is in 0..=61. // - // Per nesdev "VRC6 audio": the three channels are summed digitally, - // so a linear sum is the canonical mix. The [`VRC6_MIX_SCALE`] = 979 - // factor makes a single full-volume pulse ~1.5x the 2A03 pulse (the - // hardware/Mesen2/`db_vrc6` level); the full three-channel peak - // `(61 - 30) * 979 = 30349` stays below `i16::MAX`. + // Per nesdev "VRC6 audio": the three channels are summed digitally + // (a 6-bit DAC over two 4-bit pulses + the high 5 bits of the saw), so a + // linear sum is the canonical mix. The [`VRC6_MIX_SCALE`] = 650 factor + // makes a single full-volume pulse ~1.0x the 2A03 pulse (the NESdev / + // field / `db_vrc6` consensus level); the full three-channel peak + // `(61 - 30) * 650 = 20150` stays below `i16::MAX`. let p1 = i16::from(self.pulse1.output()); let p2 = i16::from(self.pulse2.output()); let saw = i16::from(self.saw.output()); @@ -831,8 +842,8 @@ mod tests { // Tick once so the oscillator advances past the timer == 0 reload. m.clock_audio(); let s = m.mix_audio(); - // Centering subtracts ~30 from a 0..=61 sum, scales by 979 (v2.1.6). - // With only p1 = 15 contributing, s = (15 - 30) * 979 = -14685. + // Centering subtracts ~30 from a 0..=61 sum, scales by 650 (v2.2.7). + // With only p1 = 15 contributing, s = (15 - 30) * 650 = -9750. assert!(s < 0, "mix_audio with only p1 must be below center"); } @@ -840,11 +851,11 @@ mod tests { #[test] fn vrc6_mix_audio_silent_when_disabled() { let m = Vrc6::new(synth(8), synth_chr(8), 24, Mirroring::Vertical).unwrap(); - // All channels disabled -> outputs 0 -> sum 0 -> mix = (0 - 30) * 979. + // All channels disabled -> outputs 0 -> sum 0 -> mix = (0 - 30) * 650. // Confirm we land at the documented "center - offset" position. let mut m = m; let s = m.mix_audio(); - assert_eq!(s, -29370); + assert_eq!(s, -19500); } #[test] diff --git a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs index 84058c4b..7302f4f5 100644 --- a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs +++ b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs @@ -81,17 +81,40 @@ fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize { /// widened that return to `i32` and calibrated the level; see /// [`SUNSOFT5B_MIX_SCALE_NUM`] and `docs/accuracy-ledger.md`. /// -/// Per the NESdev "Sunsoft 5B audio" page, the chip's DAC has a 1.5 dB -/// step on the 5-bit signal. Because the wiki specifies that envelope -/// level `e` is equivalent to 4-bit volume `e >> 1` (with both `e=0` and -/// `e=1` mapping to silence), a 16-entry table indexed by the 4-bit -/// equivalent is sufficient — equivalent to a 32-entry table where each -/// even/odd pair shares the same amplitude. +/// Per the NESdev "Sunsoft 5B audio" page, the chip's DAC has a **1.5 dB step +/// on the 5-bit signal** — "some emulator implementations based on the +/// AY-3-8910 instead treat it as a 4-bit signal with a 3 dB per step curve", +/// which the wiki flags as the *approximation*, not the exact behavior. +/// +/// A channel driven by its **fixed 4-bit volume** register genuinely steps +/// 3 dB (it selects every other level of the 5-bit DAC), so this 16-entry +/// table — the 4-bit projection — is exact for fixed-volume tones. A channel +/// driven by the **envelope generator** produces the full 5-bit level and must +/// use the 32-entry [`SUNSOFT5B_LOG_VOL32`] table for the true 1.5 dB/step +/// resolution (v2.2.7 "Timbre II": before, the envelope was reduced to 4-bit +/// via `e >> 1`, collapsing it onto this coarser 3 dB curve — the approximation +/// the wiki names; nestopia and rustico use the exact 5-bit DAC, which this now +/// matches). The odd entries of `SUNSOFT5B_LOG_VOL32` equal this table exactly. #[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))] const SUNSOFT5B_LOG_VOL: [i32; 16] = [ 0, 15, 21, 30, 42, 59, 84, 119, 168, 237, 335, 473, 668, 944, 1333, 1882, ]; +/// Exact **5-bit, 1.5 dB/step** logarithmic DAC (v2.2.7 "Timbre II") — the +/// NESdev-authoritative Sunsoft-5B envelope curve, matching nestopia / rustico. +/// Indexed by the envelope generator's full 5-bit output (0..=31). Each step is +/// `×1.1885` (= +1.5 dB); the finest quantization is the same 1882-scaled law as +/// [`SUNSOFT5B_LOG_VOL`], so the two never drift. Per the wiki, envelope levels +/// `e=0` and `e=1` both map to silence. The **odd** entries reproduce the 4-bit +/// [`SUNSOFT5B_LOG_VOL`] table exactly (`LOG_VOL32[2v+1] == LOG_VOL[v]`), so a +/// fixed-volume channel and an envelope channel resting on the same level agree +/// to the bit — guaranteed by the `log_vol32_odd_entries_match_4bit` unit test. +#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))] +const SUNSOFT5B_LOG_VOL32: [i32; 32] = [ + 0, 0, 13, 15, 18, 21, 25, 30, 35, 42, 50, 59, 71, 84, 100, 119, 141, 168, 199, 237, 282, 335, + 398, 473, 562, 668, 794, 944, 1122, 1333, 1584, 1882, +]; + /// Mixed centering bias: subtracted from the scaled linear sum before emitting /// the i32 sample. We use a *constant zero* — the APU mixer's chained /// high-pass filters (90 Hz / 440 Hz, see `rustynes-apu::mixer::OnePole`) @@ -439,17 +462,21 @@ impl Sunsoft5BAudio { (mixer >> (ch * 2 + 1)) & 1 == 0 } - /// Resolve the 4-bit equivalent volume for channel `ch` (0/1/2 for - /// A/B/C), honoring the per-channel envelope-mode bit. - fn volume(&self, ch: u8) -> u8 { + /// Resolve channel `ch`'s DAC amplitude (0/1/2 for A/B/C), honoring the + /// per-channel envelope-mode bit. + /// + /// Fixed-volume mode indexes the 4-bit [`SUNSOFT5B_LOG_VOL`] table (exact + /// 3 dB/step for a 4-bit register). Envelope mode indexes the full 5-bit + /// [`SUNSOFT5B_LOG_VOL32`] table for the NESdev-exact **1.5 dB/step** + /// resolution (v2.2.7 "Timbre II"; previously the 5-bit envelope was + /// truncated to 4-bit via `>> 1`, i.e. the wiki-named 3 dB approximation). + /// `env=0`/`env=1` both map to silence; `env=31` is full scale. + fn amplitude(&self, ch: u8) -> i32 { let reg = self.regs[0x08 + ch as usize]; if reg & 0x10 != 0 { - // Envelope mode: 5-bit env mapped to 4-bit equivalent via `>>1` - // per the NESdev table (env=0/1 both -> silent; env=2 -> vol 1; - // env=31 -> vol 15). - self.envelope.output() >> 1 + SUNSOFT5B_LOG_VOL32[self.envelope.output() as usize & 0x1F] } else { - reg & 0x0F + SUNSOFT5B_LOG_VOL[(reg & 0x0F) as usize] } } @@ -484,8 +511,7 @@ impl Sunsoft5BAudio { let tone_factor = !self.tone_enabled(ch) || tone.level != 0; let noise_factor = !self.noise_enabled(ch) || self.noise.level() != 0; if tone_factor && noise_factor { - let v = self.volume(ch) as usize & 0x0F; - sum += SUNSOFT5B_LOG_VOL[v]; + sum += self.amplitude(ch); } } // Scale the shape table to the hardware-relative level (see @@ -1112,30 +1138,51 @@ mod tests { // contribution range. assert_eq!(SUNSOFT5B_LOG_VOL[0], 0); assert!(SUNSOFT5B_LOG_VOL[15] > SUNSOFT5B_LOG_VOL[14]); - // The volume() helper applies the envelope-mode select bit. + // amplitude() applies the envelope-mode select bit and returns the DAC + // value (not the 4-bit index). let mut a = Sunsoft5BAudio::default(); a.regs[0x08] = 0x0F; // fixed volume = 15. - assert_eq!(a.volume(0), 15); + assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL[15]); a.regs[0x08] = 0x00; // fixed volume = 0. - assert_eq!(a.volume(0), 0); + assert_eq!(a.amplitude(0), 0); } #[test] fn sunsoft5b_envelope_mode_routes_envelope_into_channel() { - // Setting bit 4 of $08/$09/$0A switches that channel from fixed - // volume to envelope mode. In envelope mode the 4-bit volume - // equivalent is env >> 1 (per the NESdev table). + // Setting bit 4 of $08/$09/$0A switches that channel from fixed volume + // to envelope mode. In envelope mode (v2.2.7 "Timbre II") the FULL 5-bit + // envelope level indexes the exact 1.5 dB/step SUNSOFT5B_LOG_VOL32 DAC — + // no longer truncated to 4-bit via `>> 1`. let mut a = Sunsoft5BAudio::default(); a.regs[0x08] = 0x10; // envelope mode, fixed-volume bits ignored. - a.envelope.level = 30; // 4-bit equivalent = 15. - assert_eq!(a.volume(0), 15); - a.envelope.level = 6; - assert_eq!(a.volume(0), 3); + a.envelope.level = 31; // full scale. + assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL32[31]); + a.envelope.level = 6; // an even (previously-truncated) level. + assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL32[6]); a.envelope.level = 1; - assert_eq!(a.volume(0), 0); // env 0 and 1 both -> 0. - // Switching back to fixed mode honors $08 bits 3-0 again. + assert_eq!(a.amplitude(0), 0); // env 0 and 1 both -> silence. + a.envelope.level = 0; + assert_eq!(a.amplitude(0), 0); + // Switching back to fixed mode honors $08 bits 3-0 again (4-bit DAC). a.regs[0x08] = 0x07; - assert_eq!(a.volume(0), 7); + assert_eq!(a.amplitude(0), SUNSOFT5B_LOG_VOL[7]); + } + + #[test] + fn log_vol32_odd_entries_match_4bit() { + // The 4-bit fixed-volume table is exactly the odd levels of the 5-bit + // envelope DAC, so a fixed-volume channel and an envelope channel + // resting on the same level agree to the bit. + for v in 0..16 { + assert_eq!(SUNSOFT5B_LOG_VOL32[2 * v + 1], SUNSOFT5B_LOG_VOL[v]); + } + // Envelope levels 0 and 1 are both silence (per the NESdev wiki). + assert_eq!(SUNSOFT5B_LOG_VOL32[0], 0); + assert_eq!(SUNSOFT5B_LOG_VOL32[1], 0); + // A strictly increasing log ramp above the silent floor. + for e in 3..32 { + assert!(SUNSOFT5B_LOG_VOL32[e] > SUNSOFT5B_LOG_VOL32[e - 1]); + } } #[cfg(feature = "mapper-audio")] diff --git a/crates/rustynes-test-harness/tests/audio_expansion.rs b/crates/rustynes-test-harness/tests/audio_expansion.rs index c1b35c99..3442b606 100644 --- a/crates/rustynes-test-harness/tests/audio_expansion.rs +++ b/crates/rustynes-test-harness/tests/audio_expansion.rs @@ -21,8 +21,8 @@ //! | ROM | comparison | target ratio | //! |------------|-----------------------------------|--------------| //! | `db_apu` | APU triangle / APU square | ~ 0.524 | -//! | `db_vrc6a` | VRC6 square / APU square | ~ 1.506 | -//! | `db_vrc6b` | VRC6 square / APU square (Madara) | ~ 1.506 | +//! | `db_vrc6a` | VRC6 square / APU square | ~ 1.000 | +//! | `db_vrc6b` | VRC6 square / APU square (Madara) | ~ 1.000 | //! | `db_mmc5` | MMC5 square / APU square | ~ 1.000 | //! | `db_n163` | N163 1-ch square / APU square | ~ 6.02 | //! @@ -161,16 +161,18 @@ fn level_db_apu() { #[test] fn level_db_vrc6a() { - // VRC6a (Akumajou Densetsu pinout): a full-volume VRC6 square is ~1.5× the - // 2A03 pulse (Mesen2 weights VRC6 `output*15` internally × `*5` mixer = - // `15*15*5/746.9 ≈ 1.506`). See `VRC6_MIX_SCALE` in `m024_vrc6.rs`. - assert_ratio("db_vrc6a.nes", 1.506, 0.04); + // VRC6a (Akumajou Densetsu pinout): a full-volume VRC6 square is ~1.0× the + // 2A03 pulse. Recalibrated in v2.2.7 "Timbre II" from the prior Mesen2-derived + // ~1.506× (Mesen2 weights VRC6 louder) to the NESdev / bbbradsmith-`db_vrc6` / + // wider-field consensus that a VRC6 pulse ≈ a 2A03 pulse (rustico / tetanes / + // BizHawk encode exactly 1.0×). See `VRC6_MIX_SCALE` (= 650) in `m024_vrc6.rs`. + assert_ratio("db_vrc6a.nes", 1.0, 0.04); } #[test] fn level_db_vrc6b() { // VRC6b (Madara pinout): identical audio path to VRC6a, same target. - assert_ratio("db_vrc6b.nes", 1.506, 0.04); + assert_ratio("db_vrc6b.nes", 1.0, 0.04); } #[test] diff --git a/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6a.snap b/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6a.snap index 9e1cf74c..8365f2bd 100644 --- a/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6a.snap +++ b/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6a.snap @@ -1,5 +1,6 @@ --- source: crates/rustynes-test-harness/tests/audio_expansion.rs +assertion_line: 283 expression: snap --- -rom=audio-tests/db_vrc6a.nes frames=660 fb_bytes=245760 fb_fnv1a64=1719dca5cef7a325 cycles=19625460 audio_samples=483554 audio_fnv1a64=85d88e2845bbdb94 +rom=audio-tests/db_vrc6a.nes frames=660 fb_bytes=245760 fb_fnv1a64=1719dca5cef7a325 cycles=19625460 audio_samples=483554 audio_fnv1a64=63bac7ff2499b0a7 diff --git a/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6b.snap b/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6b.snap index d74c9784..95497142 100644 --- a/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6b.snap +++ b/crates/rustynes-test-harness/tests/snapshots/audio_expansion__audio_expansion_db_vrc6b.snap @@ -1,5 +1,6 @@ --- source: crates/rustynes-test-harness/tests/audio_expansion.rs +assertion_line: 284 expression: snap --- -rom=audio-tests/db_vrc6b.nes frames=660 fb_bytes=245760 fb_fnv1a64=1719dca5cef7a325 cycles=19625460 audio_samples=483554 audio_fnv1a64=85d88e2845bbdb94 +rom=audio-tests/db_vrc6b.nes frames=660 fb_bytes=245760 fb_fnv1a64=1719dca5cef7a325 cycles=19625460 audio_samples=483554 audio_fnv1a64=63bac7ff2499b0a7 diff --git a/docs/STATUS.md b/docs/STATUS.md index ba2c4ac1..0e930a01 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,14 @@ # RustyNES — Project Status Matrix -> **Current release: v2.2.6** (2026-08-04) — **"Almanac"**, a **de-monetization and +> **Current release: v2.2.7** (2026-08-04) — **"Timbre II"**, an **expansion-audio +> fidelity** release (2nd of the v2.2.6 → v2.3.0 NESdev-remediation line): **VRC6 +> recalibrated to ~1.0× a 2A03 pulse** (the NESdev / field consensus — rustico/tetanes/ +> BizHawk encode 1.0× exactly; Mesen2's ~1.5× was the loud outlier a reviewer flagged; +> `VRC6_MIX_SCALE` 979 → 650, `db_vrc6a/b` oracle 1.506 → 1.0), and the **Sunsoft 5B +> envelope moved to the exact 5-bit 1.5 dB/step DAC** (`SUNSOFT5B_LOG_VOL32`, matching +> nestopia/rustico, instead of the 4-bit 3 dB approximation). Expansion-only: the base +> 2A03 output is byte-identical (**AccuracyCoin 141/141**, nestest 0-diff; base BLEP +> verified at 81.6 dB SFDR). Built on **v2.2.6** — **"Almanac"**, a **de-monetization and > provenance** release opening the **v2.2.6 → v2.3.0** NESdev-remediation line, with > **zero emulation-core behavior changes** (so **AccuracyCoin holds 141/141 (100.00%)**, > nestest 0-diff, and the `#![no_std]` chip stack, save-state / TAS / netplay formats, diff --git a/docs/accuracy-ledger.md b/docs/accuracy-ledger.md index 675734bc..1589090c 100644 --- a/docs/accuracy-ledger.md +++ b/docs/accuracy-ledger.md @@ -44,8 +44,8 @@ disposition under the v2.1.0 "Fathom" accuracy-remediation line | BestEffort mapper tier (26 families, was 112) | Register-decode + save-state round-trip only; off the oracle gate | `mapper_tier_honesty.rs` invariant | **Mostly remediated** (F3): 86 promoted to Curated with commercial-ROM oracle; the 26 left have no cleanly-booting dump (16 NES 2.0 high-id + 8 no-cart + 2 jam-at-boot) | | MMC3 R1/R2 scanline-IRQ (ADR 0002) | ≤1-CPU-cycle differential on 4 `#[ignore]`'d sub-tests; zero game impact | `mmc3_test_2/4` #3 + siblings; `mmc3_r1r2_phase_probe` A12-phase golden probe (v2.1.5, `--features mmc3-a12-phase-probe`) | **CLOSED for the shipping default; axis-B candidate deferred to maintainer** (F5.0, ADR 0002). v2.1.5 direct instrumentation refined the closure: "no post-access qualifying rise" is ROM-specific (holds for the two `scanline_timing` #3 residuals, `irq_post=0`; **false** for `mmc3_test_v1/5`+`/6` #2, `irq_post=4` — post-access IRQ-clocking rises Session B never measured). Every *tested* lever stays non-curative (incl. the `mmc3-m2-phase-irq` deferral, byte-identical status on `/5`+`/6`); the four pins stay `#[ignore]`'d. One untested lever — an ares-style M2-edge-precise falling-edge low-time filter — is deferred to a maintainer decision (needs a sacred-gate-risking substrate change to prototype) | | APU non-linear mixer | Lookup-table matches within the `apu_mixer` band | `apu_mixer` (analog-cancellation, tolerance) | **No stricter oracle** — the LUT already passes; ±4% is honest | -| Expansion-audio levels — VRC6 / MMC5 / N163 (v2.1.6) | Full-volume expansion square vs 2A03 pulse: VRC6 ≈1.51×, MMC5 ≈1.0× ("equivalent"), N163 1-ch ≈6.0×. Calibrated to Mesen2 mixer weights (the accuracy bar), cross-checked vs nestopia/puNES/fceux/tetanes | bbbradsmith `db_vrc6a/b`, `db_mmc5`, `db_n163` via the `audio_expansion.rs` `level_db_*` oracle (measured peak ratio) | **Remediated** (v2.1.6) — scales `VRC6_MIX_SCALE=979`, MMC5 `650/40`, `NAMCO163_MIX_SCALE=261` (were 256, 256/16, 64 — N163 was ~12 dB too quiet). Base-APU byte-identity preserved (expansion is a separate additive `mix_audio` term = 0 for non-expansion mappers) | -| Expansion-audio level — Sunsoft 5B absolute (v2.1.6 → **v2.2.3**) | Log-volume DAC *shape* exact (`×1.4126`/step, `LUT[12]=668`/`LUT[15]=1882` vs Mesen2 `[63,177]`). The *absolute* level is now calibrated too: `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471`, measured (`db_5b` read `0.0685×`, ~23 dB too quiet) against the Mesen2-derived target `LUT[12]=63 × weight 15 / 746.9 = 1.265×` (full scale `177×15/746.9 = 3.554×`) | `db_5b` via the new `level_db_5b` oracle (`1.265 ± 0.04`, measured **1.2651**); step law by `sunsoft5b_volume_dac_follows_logarithmic_step_law` | **REMEDIATED (v2.2.3, A1)** — the blocker was never the value but the type: `Mapper::mix_audio` returned `i16` and the corrected full-scale tone is `1882 × 18.471 = 34,761`, past `i16::MAX` for ONE channel (three tones ≈104 k, 3.2× over). Widening the trait return to **`i32`** unblocked it; shape and level are now separately pinned. Non-5B boards return the values they always did — the widening is representational only, and the other 24 audio-expansion tests plus AccuracyCoin 141/141 are unchanged | +| Expansion-audio levels — VRC6 / MMC5 / N163 (v2.1.6; VRC6 recalibrated **v2.2.7**) | Full-volume expansion square vs 2A03 pulse: VRC6 ≈1.0× (was ≈1.51×), MMC5 ≈1.0× ("equivalent"), N163 1-ch ≈6.0×. MMC5/N163 calibrated to Mesen2 mixer weights (the accuracy bar), cross-checked vs nestopia/puNES/fceux/tetanes; VRC6 now calibrated to the NESdev/field consensus instead | bbbradsmith `db_vrc6a/b`, `db_mmc5`, `db_n163` via the `audio_expansion.rs` `level_db_*` oracle (measured peak ratio) | **Remediated** (v2.1.6); **VRC6 re-remediated (v2.2.7 "Timbre II")** — the v2.1.6 VRC6 target (`VRC6_MIX_SCALE=979`, ≈1.51×) reproduced Mesen2's `×5` mixer weight, but that convention is a Mesen2-only outlier: the NESdev wiki states the VRC6 pulse is loudness-equivalent to a 2A03 pulse, and rustico/tetanes/BizHawk each encode that equivalence exactly (ares/higan/nestopia reach it via a `sum/61` normalization). `VRC6_MIX_SCALE` moved 979 → 650 (≈1.0×); the `db_vrc6a/b` snapshots were re-blessed (audio-only, framebuffer/cycle-count byte-identical). MMC5 `650/40`, `NAMCO163_MIX_SCALE=261` unchanged since v2.1.6 (were 256, 256/16, 64 — N163 was ~12 dB too quiet). Base-APU byte-identity preserved (expansion is a separate additive `mix_audio` term = 0 for non-expansion mappers) | +| Expansion-audio level — Sunsoft 5B absolute (v2.1.6 → **v2.2.3**) | Log-volume DAC *shape* exact (`×1.4126`/step, `LUT[12]=668`/`LUT[15]=1882` vs Mesen2 `[63,177]`). The *absolute* level is now calibrated too: `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471`, measured (`db_5b` read `0.0685×`, ~23 dB too quiet) against the Mesen2-derived target `LUT[12]=63 × weight 15 / 746.9 = 1.265×` (full scale `177×15/746.9 = 3.554×`) | `db_5b` via the new `level_db_5b` oracle (`1.265 ± 0.04`, measured **1.2651**); step law by `sunsoft5b_volume_dac_follows_logarithmic_step_law` | **REMEDIATED (v2.2.3, A1)** — the blocker was never the value but the type: `Mapper::mix_audio` returned `i16` and the corrected full-scale tone is `1882 × 18.471 = 34,761`, past `i16::MAX` for ONE channel (three tones ≈104 k, 3.2× over). Widening the trait return to **`i32`** unblocked it; shape and level are now separately pinned. Non-5B boards return the values they always did — the widening is representational only, and the other 24 audio-expansion tests plus AccuracyCoin 141/141 are unchanged. **v2.2.7 "Timbre II"** completed the DAC shape for envelope mode: the 5-bit hardware envelope level previously truncated to 4 bits (`>> 1`) before indexing the 16-entry `SUNSOFT5B_LOG_VOL` shape table (the wiki's documented 3 dB/step approximation), and now indexes a new 32-entry `SUNSOFT5B_LOG_VOL32` table (`×1.1885`/step = 1.5 dB, matching nestopia/rustico) with the full 5-bit level — `LOG_VOL32`'s odd entries equal `LOG_VOL` exactly (`log_vol32_odd_entries_match_4bit`). Fixed 4-bit volume (already correct 3 dB/step) and the absolute level (1.265×) are unchanged; committed 5B snapshots stayed byte-identical since the extant 5B test ROMs don't exercise odd envelope levels | | Expansion-audio level — VRC7 FM (v2.1.6) | OPLL FM synth implemented (emu2413 MIT port); instrument patch ROM byte-for-byte canonical (Nuke.YKT); absolute FM level vs APU square is patch-/TL-/feedback-dependent pseudo-sine, ≈2.7× peak per Mesen2 emu2413 but not a clean square-vs-square oracle | `patch_vrc7` = `vrc7_all_15_melodic_patches_match_nuke_ykt_canonical` unit test (real); `db_vrc7`/`clip_vrc7` snapshot-guarded | **Patch verified; level snapshot-guarded** — the FM synthesis + instrument set are correct; the absolute output level is not oracle-pinned (no square-vs-square reference for a pseudo-sine) | | PAL APU frame-counter step positions | **Modeled (v2.1.5):** `frame_counter.rs` now clocks the PAL (2A07) sequencer at 8313/16627/24939/33252-33254 (4-step) and 8313/16627/24939/41565-41566 (5-step), region-gated by `FrameCounter::pal` (true only for `Region::Pal`; NTSC/Dendy keep 7457/14913/22371/29828-30, 37281-82). PAL *scheduler* timing (3.2:1, 50 Hz, PAL DMC/noise tables) was already modeled | `pal_apu_tests` (10 sub-ROMs, forced PAL, on-screen verdict via `run_nes_screen`) | **10/10 pass** — the 3 region-independent checks + the 5 PAL frame-counter-timing checks (clock jitter, mode-0/1 length timing, IRQ-flag/IRQ timing) that flipped to PASS with the PAL step positions, plus `10.len_halt_timing` / `11.len_reload_timing` (see the next row). NTSC byte-identity preserved (AccuracyCoin APU 141/141, `apu_test` 8/8, NTSC `blargg_apu_2005` 11/11 all unchanged). | | Length halt/reload write-vs-half-frame-clock ordering | **Modeled (v2.1.5):** the length counter (`length.rs`) now defers a halt change (`new_halt`) and a length reload (`reload_val` + `previous_count` snapshot); the APU promotes both once per CPU cycle in `tick_with_external`, AFTER the half-frame length clock and BEFORE the mixer sample — so a halt/reload write that coincides with the clock is applied *after* it (halt) or dropped when the counter was clocked non-zero (reload). Mirrors `TetaNES` `LengthCounter::reload` + Mesen2 `_newHaltValue`. | `pal_apu_tests` `10.len_halt_timing`, `11.len_reload_timing` (forced PAL, on-screen verdict); NTSC `blargg_apu_2005` 10 & 11 + `f2a_*` (`f2_accuracy_audit.rs`) | **CLOSED (v2.1.5)** — both PAL ROMs now report on-screen `PASSED` (was `FAILED: #3` / `#4`). Region-agnostic ordering fix: byte-identical on NTSC (the reload settles in-cycle on the common non-coincident write, and halt does not affect channel output directly), so AccuracyCoin 141/141, `blargg_apu_2005` 11/11 and `f2_accuracy_audit` 6/6 are all unchanged. | diff --git a/docs/apu-2a03.md b/docs/apu-2a03.md index 2760447b..f60c388d 100644 --- a/docs/apu-2a03.md +++ b/docs/apu-2a03.md @@ -337,20 +337,20 @@ All synth cores are behind the default-on `mapper-audio` Cargo feature; when it ### Expansion-audio levels (v2.1.6 "Expansion Audio") -Each chip's `mix_audio()` is scaled so its full-volume square sits at the **relative loudness the hardware and Mesen2 (RustyNES's accuracy bar) produce vs the 2A03 pulse**, measured by the bbbradsmith `db_*` decibel-comparison ROMs. The reference is Mesen2 `NesSoundMixer::GetOutputVolume` (2A03 pulse peak `95.88*5000/(8128/15+100) ≈ 746.9`; linear expansion weights VRC6 `×5`·internally-`×15`, MMC5 `×43`, N163 `×20`, 5B `×15`, VRC7 `×1`), cross-checked against nestopia / puNES / fceux / tetanes. The `crates/rustynes-test-harness/tests/audio_expansion.rs` `level_db_*` oracle asserts the measured expansion-vs-reference ratio from each ROM's rendered waveform: +Each chip's `mix_audio()` is scaled so its full-volume square sits at the **relative loudness the hardware produces vs the 2A03 pulse**, calibrated against the reference-emulator field (Mesen2 was RustyNES's historical accuracy bar, but VRC6 was recalibrated *away* from it in v2.2.7 — Mesen2 is the loud outlier for VRC6; see the v2.2.7 note below), measured by the bbbradsmith `db_*` decibel-comparison ROMs. The reference is Mesen2 `NesSoundMixer::GetOutputVolume` (2A03 pulse peak `95.88*5000/(8128/15+100) ≈ 746.9`; linear expansion weights VRC6 `×5`·internally-`×15`, MMC5 `×43`, N163 `×20`, 5B `×15`, VRC7 `×1`), cross-checked against nestopia / puNES / fceux / tetanes. **v2.2.7 "Timbre II" re-corrected the VRC6 target away from that Mesen2 weighting** — a NESdev-forum reviewer flagged VRC6 as too loud, and a cross-reference across the eleven reference emulators vendored under `ref-proj/` plus the NESdev wiki confirmed Mesen2's `×5` is the outlier, not the field: the wiki states that "at maximum volume, the pulse channels of the VRC6 are roughly equivalent to the pulse channels of the 2A03," and rustico / tetanes / BizHawk each encode a VRC6 pulse as *exactly* a 2A03 pulse (ares / higan / nestopia reach the same figure via a `sum/61` normalization). MMC5 / N163 / 5B keep their Mesen2-derived targets, which the same cross-reference corroborates. The `crates/rustynes-test-harness/tests/audio_expansion.rs` `level_db_*` oracle asserts the measured expansion-vs-reference ratio from each ROM's rendered waveform: | Chip (ROM) | Target ratio vs APU square | RustyNES scale (`mix_audio`) | Status | |-------------------|----------------------------|--------------------------------------|--------| | APU triangle (`db_apu`) | ≈ 0.524 (fixed 2A03 DAC balance) | `pulse_table` / `tnd_table` LUT | **Asserted** | -| VRC6 (`db_vrc6a/b`) | ≈ 1.506 | `VRC6_MIX_SCALE = 979` (`m024_vrc6.rs`; was 256) | **Asserted** (v2.1.6) | +| VRC6 (`db_vrc6a/b`) | ≈ 1.000 | `VRC6_MIX_SCALE = 650` (`m024_vrc6.rs`; was 979) | **Asserted** (recalibrated v2.2.7) | | MMC5 (`db_mmc5`) | ≈ 1.000 ("equivalent to APU") | pulse `×650` / PCM `×40` (`m005_mmc5.rs`; was 256/16) | **Asserted** (v2.1.6) | | Namco 163 1-ch (`db_n163`) | ≈ 6.02 | `NAMCO163_MIX_SCALE = 261` (`m019_namco163.rs`; was 64) | **Asserted** (v2.1.6) | | Sunsoft 5B (`db_5b`) | ≈ 1.265 (vol-12) / 3.554 (vol-15) | shape `SUNSOFT5B_LOG_VOL` + level `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138` | **Asserted** (v2.2.3) | | VRC7 (`db_vrc7`) | ≈ 2.7 peak (patch-dependent) | raw `Opll::calc()` (`±4095`) | **Snapshot-guarded** — see below | -VRC6 (1.506), MMC5 (1.0) and N163 (6.02) were the v2.1.6 level corrections; MMC5's `mix_audio` bias moves to `-12290` accordingly. **VRC6/MMC5/N163 fixes touch only the expansion channel** — the base 2A03 mix is a separate additive term (`mix_audio() == 0` for non-expansion mappers), so AccuracyCoin / blargg / nestest stay byte-identical. +VRC6 (1.506), MMC5 (1.0) and N163 (6.02) were the v2.1.6 level corrections; MMC5's `mix_audio` bias moves to `-12290` accordingly. **v2.2.7 "Timbre II" superseded the VRC6 figure**: the ~1.506× target mirrored Mesen2's specifically louder `×5` mixer weight, but the wider field (rustico / tetanes / BizHawk / ares / higan / nestopia) and the NESdev wiki agree VRC6's pulse is loudness-*equivalent* to the 2A03 pulse, so the target moved to ≈1.000 and `VRC6_MIX_SCALE` moved 979 → 650. The `db_vrc6a/b` snapshots were re-blessed for the new waveform amplitude (audio-only; framebuffer and cycle count are byte-identical). VRC6's per-channel balance — saw 0-31 linearly summed against pulse 0-15 — was already correct and is unchanged. **VRC6/MMC5/N163 fixes touch only the expansion channel** — the base 2A03 mix is a separate additive term (`mix_audio() == 0` for non-expansion mappers), so AccuracyCoin / blargg / nestest stay byte-identical. -**Sunsoft 5B absolute level — closed in v2.2.3 (A1).** The log-volume DAC *shape* was always hardware-exact (`×1.4126`/step, verified by `sunsoft5b_volume_dac_follows_logarithmic_step_law`); the *level* was deferred for one reason only — `Mapper::mix_audio` returned `i16`, and a full-volume tone at the `db_5b` level is `1882 × 18.471 = 34,761`, past `i16::MAX` for a single channel (three simultaneous tones ≈104 k, 3.2× over). The trait return is now **`i32`**, and the level is calibrated by `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471`: measured `0.0685×` before (~23 dB too quiet), **1.2651×** after, against the Mesen2-derived target `LUT[12]=63 × weight 15 / 746.9 = 1.265`. Asserted by `level_db_5b`. Shape and level are now separately pinned, each by its own oracle. The widening is representational for every other board — they return the values they always did. +**Sunsoft 5B absolute level — closed in v2.2.3 (A1).** The log-volume DAC *shape* was always hardware-exact (`×1.4126`/step, verified by `sunsoft5b_volume_dac_follows_logarithmic_step_law`); the *level* was deferred for one reason only — `Mapper::mix_audio` returned `i16`, and a full-volume tone at the `db_5b` level is `1882 × 18.471 = 34,761`, past `i16::MAX` for a single channel (three simultaneous tones ≈104 k, 3.2× over). The trait return is now **`i32`**, and the level is calibrated by `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471`: measured `0.0685×` before (~23 dB too quiet), **1.2651×** after, against the Mesen2-derived target `LUT[12]=63 × weight 15 / 746.9 = 1.265`. Asserted by `level_db_5b`. Shape and level are now separately pinned, each by its own oracle. The widening is representational for every other board — they return the values they always did. **v2.2.7 "Timbre II" completed the envelope-mode DAC**: the 5-bit hardware envelope previously truncated to 4 bits (`>> 1`) before indexing the 16-entry `SUNSOFT5B_LOG_VOL` table — the wiki-documented 3 dB/step approximation — and now indexes a new 32-entry `SUNSOFT5B_LOG_VOL32` table (`×1.1885`/step = 1.5 dB, matching nestopia / rustico) with the full 5-bit level, so odd envelope levels get their own DAC step instead of sharing their neighbor's. `LOG_VOL32`'s odd entries equal `LOG_VOL` exactly, guarded by `log_vol32_odd_entries_match_4bit`. Fixed 4-bit volume (already the correct 3 dB/step for a 4-bit register) and the absolute level (1.265×) are unchanged; every committed 5B snapshot stayed byte-identical since the extant 5B test ROMs don't exercise odd envelope levels. One level remains an honest documented gap (`docs/accuracy-ledger.md` §Expansion-audio levels): diff --git a/docs/expansion-audio.md b/docs/expansion-audio.md index 4f9990d5..bb7368a5 100644 --- a/docs/expansion-audio.md +++ b/docs/expansion-audio.md @@ -26,20 +26,34 @@ round-trip is preserved, but `clock`/`mix` become silent no-op shims. The VRC7 OPLL core is deliberately the **MIT `emu2413`** lineage, not the license- incompatible Nuked-OPLL. -## Relative levels (v2.1.6 "Expansion Audio") +## Relative levels (v2.1.6 "Expansion Audio", VRC6 recalibrated v2.2.7) Each chip's `mix_audio()` is scaled so its full-volume output sits at the -loudness the hardware and **Mesen2** (RustyNES's accuracy bar) produce relative -to the 2A03 pulse, measured by the bbbradsmith `db_*` decibel-comparison ROMs and -asserted by the `audio_expansion.rs` `level_db_*` oracle. VRC6 (≈1.506), MMC5 -(≈1.0, "equivalent to the APU") and Namco 163 1-channel (≈6.02) are the pinned -corrections. Two levels are **honest documented gaps**: the Sunsoft 5B absolute -level (its log-DAC *shape* is hardware-exact, but a full-volume tone would -overflow the `i16` `mix_audio` contract — a wider mix path is deferred), and the -VRC7 FM absolute level (the FM synth is implemented and its instrument ROM is -verified canonical, but its patch/feedback-dependent pseudo-sine output is not -cleanly oracle-pinned, so `db_vrc7` stays a byte-exact snapshot regression -guard). See [`accuracy-ledger.md`](accuracy-ledger.md) § Expansion-audio levels. +loudness the **hardware** produces relative to the 2A03 pulse — calibrated against +the reference-emulator field (Mesen2 was RustyNES's historical accuracy bar, but +VRC6 was recalibrated *away* from it in v2.2.7 because Mesen2 is the loud outlier +for VRC6; see below), measured by the bbbradsmith `db_*` decibel-comparison ROMs +and asserted by the `audio_expansion.rs` `level_db_*` oracle. VRC6 (≈1.0, v2.2.7), +MMC5 (≈1.0, "equivalent to the APU") and Namco 163 1-channel (≈6.02) are the +pinned corrections. **VRC6 was recalibrated in v2.2.7 "Timbre II"**: the v2.1.6 +target (≈1.506) mirrored Mesen2's specifically louder `×5` mixer weight, but a +NESdev-forum report plus a cross-reference across eleven reference emulators and +the NESdev wiki found Mesen2 to be the loud outlier — the wiki states the VRC6 +pulse channels are roughly loudness-equivalent to the 2A03's, and rustico / +tetanes / BizHawk each encode that equivalence exactly — so the target moved to +≈1.0 and `VRC6_MIX_SCALE` moved 979 → 650 (`m024_vrc6.rs`). One level remains an +**honest documented gap**: the VRC7 FM absolute level (the FM synth is implemented +and its instrument ROM is verified canonical, but its patch/feedback-dependent +pseudo-sine output is not cleanly oracle-pinned, so `db_vrc7` stays a byte-exact +snapshot regression guard). (The Sunsoft 5B absolute level was itself an +`i16`-`mix_audio`-contract gap until **v2.2.3** widened the trait return to `i32` +and pinned it via `level_db_5b`; its log-DAC *shape* is hardware-exact and, since +v2.2.7, its envelope uses the exact 5-bit 1.5 dB/step DAC — see below.) Separately, v2.2.7 also +completed the Sunsoft 5B **envelope** DAC: the envelope-mode volume now indexes +a 32-level, 1.5 dB/step table (`SUNSOFT5B_LOG_VOL32`) instead of truncating to +the 4-bit, 3 dB/step approximation, matching nestopia / rustico; the fixed +4-bit tone volume and the absolute 5B level are unchanged. See +[`accuracy-ledger.md`](accuracy-ledger.md) § Expansion-audio levels. Because a non-expansion mapper's `mix_audio()` returns `0`, the expansion mix is a **separate additive term** — the level corrections leave the base 2A03 mix