diff --git a/AGENTS.md b/AGENTS.md index afad84af..0b056033 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.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. +**Current release: v2.2.8 "Aperture II"** (2026-08-04, a presentation-fidelity release — gamma-correct scanlines in linear light + a WebGL2 gamma fix + a sharper Gaussian scanline profile in the base `CRT_WGSL`; presentation-only, so the pre-shader framebuffer + AccuracyCoin 141/141 are byte-identical and the shipped native default is unchanged [the native sRGB surface passes `aux = 0`, which selects the exact pre-v2.2.8 output; the new linear-light + sharpness path is keyed on a non-zero `aux`, set on the WebGL2 non-sRGB path and when the scanline knob is raised]; the shader/appearance changes await on-display + browser visual verification), on top of **v2.2.7 "Timbre II"** (2026-08-04) — an **expansion-audio fidelity** release (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. @@ -43,7 +43,7 @@ The prior release, **v2.2.0 "Capstone"** (2026-07-12), was the **milestone cut** - **Mapper breadth → 172 families** (up from 168 at the v1.7.x tag), Core / Curated / BestEffort behind the CI accuracy-honesty gate. - **Release automation** — `.github/workflows/release-auto.yml`: when a new version goes final-green on `main`, it auto-tags + publishes the GitHub Release (body from a maintainer-authored `.github/release-notes/vX.Y.Z.md` override, else the CHANGELOG `[X.Y.Z]` section; title codename parsed from the CHANGELOG header) and builds + attaches the desktop binaries by invoking `release.yml` via `workflow_call` (a tag pushed by `GITHUB_TOKEN` can't trigger `on: push: tags`, hence the direct call). The v1.8.0–v1.9.9 GitHub Releases are all published with comprehensive notes + Linux / macOS-aarch64 / Windows binaries. -Platform additions through v1.10.0 were **host-only and additive**: the deterministic `#![no_std]` chip stack was untouched and byte-identical on ARM. **v2.0.0 "Timebase" is different by design** — it rewrites the scheduler substrate itself (still `#![no_std]`-clean, AccuracyCoin now back at a full **141/141 (100%)** from v2.0.3 — see above, but the save-state / movie format epochs deliberately bump per ADR 0028, so cross-version `.rns`/`.rnm` round-trip is a v1.x-only guarantee, not a v1.x⇄v2.x one). Forward path: the **v2.0.x "Harbor" mobile-finalization re-port train** onto the v2.0.0 core has fully shipped — v2.0.1 (first Android re-port + AccuracyCoin oracle re-sync), v2.0.2–v2.0.3 (the 2-cycle-ALE accuracy closure to 141/141), v2.0.4 (Android release candidate), v2.0.5–v2.0.8 (iOS finalization), and v2.0.9 (both-apps readiness) — followed by the **v2.1.x "Fathom" accuracy line** (v2.1.0 → v2.1.10) capped by the **v2.2.0 "Capstone"** milestone cut, then the v2.2.1 housekeeping patch, **v2.2.2 "Conduit"**, **v2.2.3 "Datum"**, and **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut), the current release; see the "Current release" paragraph above. The **v2.1.5 → v2.2.0** line is a **"deepen the existing project"** run (accuracy / performance / features / quality); **v2.1.5 "Vernier"** opened it (the tepples Holy Mapperel mapper bank-reachability / IRQ regression net, the first PAL-region APU oracle at `pal_apu_tests` 10/10, the MMC3 R1/R2 F5.0 A12-phase study, a measured fat-LTO A/B, and a real TURN NAT-traversal retransmit production fix — all NTSC-byte-identical), **v2.1.6 "Timbre"** continued it (the expansion-audio decibel oracle, the hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, and a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical), **v2.1.7 "Stepping"** added opt-in PPU / 2A03 die-revisions + power-on RAM/palette hardware models (the DMA "unexpected read" frontier proven a documented no-op on every oracle, ADR 0033 — honest, not faked), **v2.1.8 "Tempo"** the default-OFF specialized fast PPU dot path (~12% rendering-heavy, differential-tested bit-identical) + a SIMD-validated software blitter + a wasm size pass, **v2.1.9 "Aperture"** the marquee CRT shader stack + a raw NTSC composite signal-decode path + GIF/WAV capture + a palette editor, **v2.1.10 "Loom"** the TAStudio greenzone + Lua API breadth + the browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation, and **v2.2.0 "Capstone"** the milestone cut closing the run (the netplay matchmaking / lobby stack + the FDS medium model + a peripherals & quality/security pass — fuzz targets 3 → 8, a `Movie::deserialize` OOM-DoS fix, a read-only Tools → ROM Info browser) — all NTSC-byte-identical, AccuracyCoin 141/141 throughout; the v2.1.5 → v2.2.0 run is now closed. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch** — shipping as a free FOSS app, no ads/tracking/paid unlock, per ADR 0035 — is the future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets). +Platform additions through v1.10.0 were **host-only and additive**: the deterministic `#![no_std]` chip stack was untouched and byte-identical on ARM. **v2.0.0 "Timebase" is different by design** — it rewrites the scheduler substrate itself (still `#![no_std]`-clean, AccuracyCoin now back at a full **141/141 (100%)** from v2.0.3 — see above, but the save-state / movie format epochs deliberately bump per ADR 0028, so cross-version `.rns`/`.rnm` round-trip is a v1.x-only guarantee, not a v1.x⇄v2.x one). Forward path: the **v2.0.x "Harbor" mobile-finalization re-port train** onto the v2.0.0 core has fully shipped — v2.0.1 (first Android re-port + AccuracyCoin oracle re-sync), v2.0.2–v2.0.3 (the 2-cycle-ALE accuracy closure to 141/141), v2.0.4 (Android release candidate), v2.0.5–v2.0.8 (iOS finalization), and v2.0.9 (both-apps readiness) — followed by the **v2.1.x "Fathom" accuracy line** (v2.1.0 → v2.1.10) capped by the **v2.2.0 "Capstone"** milestone cut, then the v2.2.1 housekeeping patch, **v2.2.2 "Conduit"**, **v2.2.3 "Datum"**, and **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut); see the "Current release" paragraph above for the actual current release. The **v2.1.5 → v2.2.0** line is a **"deepen the existing project"** run (accuracy / performance / features / quality); **v2.1.5 "Vernier"** opened it (the tepples Holy Mapperel mapper bank-reachability / IRQ regression net, the first PAL-region APU oracle at `pal_apu_tests` 10/10, the MMC3 R1/R2 F5.0 A12-phase study, a measured fat-LTO A/B, and a real TURN NAT-traversal retransmit production fix — all NTSC-byte-identical), **v2.1.6 "Timbre"** continued it (the expansion-audio decibel oracle, the hardware/Mesen2 channel-level calibration incl. the Namco 163 ~12 dB fix, VRC7 patch-set verification vs Nuke.YKT, and a frontend Audio Mixer panel — base 2A03 NTSC output byte-identical), **v2.1.7 "Stepping"** added opt-in PPU / 2A03 die-revisions + power-on RAM/palette hardware models (the DMA "unexpected read" frontier proven a documented no-op on every oracle, ADR 0033 — honest, not faked), **v2.1.8 "Tempo"** the default-OFF specialized fast PPU dot path (~12% rendering-heavy, differential-tested bit-identical) + a SIMD-validated software blitter + a wasm size pass, **v2.1.9 "Aperture"** the marquee CRT shader stack + a raw NTSC composite signal-decode path + GIF/WAV capture + a palette editor, **v2.1.10 "Loom"** the TAStudio greenzone + Lua API breadth + the browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation, and **v2.2.0 "Capstone"** the milestone cut closing the run (the netplay matchmaking / lobby stack + the FDS medium model + a peripherals & quality/security pass — fuzz targets 3 → 8, a `Movie::deserialize` OOM-DoS fix, a read-only Tools → ROM Info browser) — all NTSC-byte-identical, AccuracyCoin 141/141 throughout; the v2.1.5 → v2.2.0 run is now closed. The **joint Google Play + Apple App Store + AltStore PAL + F-Droid launch** — shipping as a free FOSS app, no ads/tracking/paid unlock, per ADR 0035 — is the future **v2.3.0** (moved from the earlier v2.1.0 / v2.2.0 targets). --- @@ -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.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. +- 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.8 "Aperture II"** (2026-08-04, a presentation-fidelity release — gamma-correct scanlines + a WebGL2 gamma fix + a sharper scanline profile; presentation-only so the pre-shader framebuffer + AccuracyCoin 141/141 are byte-identical, native default unchanged; visual verification pending), on top of **v2.2.7 "Timbre II"** (2026-08-04, an expansion-audio fidelity release — VRC6 recalibrated to ~1.0× a 2A03 pulse per the NESdev/field consensus [`VRC6_MIX_SCALE` 979→650; Mesen2's ~1.5× was the loud outlier], and the Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC; expansion-only, so the base 2A03 is byte-identical and AccuracyCoin holds 141/141), on top of **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.rs`; two optimizations measured and REJECTED and documented as such; AccuracyCoin 141/141 — on top of **v2.2.2 "Conduit"** [2026-07-21, a build/distribution/CI-integrity patch — the libretro buildbot recipe taken from 1 of 10 jobs green to all ten building, a GitHub Actions supply-chain hardening pass, and the toolchain collapsed to one pinned source of truth with no `nightly` on any build path; zero emulation-core changes], itself on **v2.2.1** [2026-07-15, a housekeeping patch: dev-tooling archival, a zero-source-change dependency consolidation, and a gitignored FDS test-corpus addition], itself on **v2.2.0 "Capstone"** [2026-07-12], the milestone cut that closes the v2.1.5 → v2.2.0 "deepen the existing project" run — its two remaining marquees the netplay matchmaking / lobby stack and the FDS medium model, atop a peripherals + quality/security pass (Famicom `$4016`-bit-2 microphone + 3×3-aperture Zapper; cargo-fuzz targets 3 → 8 finding + fixing two `Movie::deserialize` OOM-DoS paths; a read-only Tools → ROM Info browser); every change additive or default-off, AccuracyCoin 141/141) on the v2.0.0 "Timebase" one-clock / every-cycle-bus-access scheduler rewrite + Vs. `DualSystem` dual-console support. The v2.0.x "Harbor" mobile-finalization train (v2.0.1→v2.0.9) and the entire v2.1.x "Fathom" line (v2.1.0→v2.1.10) plus the v2.2.0 "Capstone" milestone have all shipped — the run's steps being v2.1.5 "Vernier" (regression-net & residual) → v2.1.6 "Timbre" (expansion-audio fidelity) → v2.1.7 "Stepping" (opt-in PPU/2A03 die-revisions + power-on RAM/palette models; the DMA "unexpected read" frontier a documented no-op on every oracle, ADR 0033) → v2.1.8 "Tempo" (a default-OFF fast PPU dot path + SIMD blitter + wasm size pass) → v2.1.9 "Aperture" (a marquee CRT shader stack + raw NTSC composite signal-decode + GIF/WAV capture + palette editor) → v2.1.10 "Loom" (TAStudio greenzone + Lua API breadth + browser-RA auth-proxy deploy stack + Vs. `DualSystem` libretro presentation) → v2.2.0 "Capstone" (the milestone cut closing the run) → v2.2.1 (housekeeping) → **v2.2.2 "Conduit"** the build/distribution/CI-integrity patch — preceded by v1.10.0 "Arcade" the native Libretro / RetroArch core, the v1.9.0→v1.9.9 iOS TestFlight train, the v1.8.0→v1.8.9 "Android" train, and the desktop-feature lineage v1.1.0→v1.7.1, all on the v1.0.0 production core (see the top "Current release" block + `docs/STATUS.md`). **Never claim any version *later* than v2.2.8 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio [v2.2.7, shipped], video/gamma [v2.2.8, presentation-fidelity], 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 4b47bca2..49379f16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,46 @@ cycle-accurate core later replaced. ## [Unreleased] +## [2.2.8] - 2026-08-04 - "Aperture II" (gamma-aware scanlines + sharper CRT) + +A **presentation-fidelity** release addressing the NESdev-forum feedback on +gamma-aware resampling and bilinear-soft scanlines. **Presentation-only — nothing +here touches the emulation core**, so the pre-shader framebuffer, save-states, and +every golden vector are byte-identical (AccuracyCoin 141/141, nestest 0-diff), and +the **shipped native default is byte-identical** to v2.2.7 (the native sRGB +surface passes `aux = 0`, which selects the exact pre-v2.2.8 scanline profile; +the new linear-light + sharper-scanline path activates only for a non-zero +`aux`, set on the WebGL2 non-sRGB path and when the scanline knob is raised). +The base BLEP audio and the advanced CRT stacks +(royale/guest/megatron, already gamma-correct) are untouched. + +> **Visual verification pending.** These are shader/appearance changes; naga +> validates that the WGSL compiles and the native/wasm builds are clean, but the +> on-screen result must be confirmed on a real display + a browser (WebGL2). + +### Changed + +- **Gamma-correct scanlines + aperture mask (base CRT pass, `CRT_WGSL`).** The + scanline/mask *darkening* now happens in **linear light**. On the native path + (sRGB texture + surface) the sampler/surface already convert, so the shader + leaves it linear (`aux.y = 0`, output byte-identical). On a plain UNORM path + (**WebGL2**, which does neither) the shader now sRGB-decodes on read and + re-encodes before output (`aux.y = 1`) — fixing a real browser-only gamma bug so + a scanline valley is 50% of the *linear* luminance, not the encoded value. The + round-trip uses the **exact IEC 61966-2-1 piecewise sRGB transfer** (the + `0.04045` / `0.0031308` breakpoints + a 2.4 exponent), not a `pow(2.2)` + approximation, so the WebGL2 result matches the hardware sRGB surface the native + path uses bit-for-bit. +- **Sharper scanlines (`aux.x`, default 0.5).** The scanline profile blends from + the original soft parabola (0) to a narrow Gaussian beam (1) for crisp vertical + boundaries instead of the linear-sampler blur — the sharper scanlines the + feedback asked for. Only visible when scanlines are enabled; `aux.x = 0` + reproduces the pre-v2.2.8 profile exactly. Wired on the desktop + (`rustynes-frontend`), Android (`rustynes-android`), and iOS Metal + (`rustynes-ios`) hosts via the shared 16-float CRT uniform + (`rect + crop + params + aux`) — all three set `aux` identically for the + scanline/CRT filters, so the corrected profile is consistent across platforms. + ## [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 diff --git a/Cargo.lock b/Cargo.lock index 9d1253cf..a72e783b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "2.2.7" +version = "2.2.8" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "2.2.7" +version = "2.2.8" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "2.2.7" +version = "2.2.8" dependencies = [ "cc", "ureq", @@ -4329,7 +4329,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "2.2.7" +version = "2.2.8" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4346,7 +4346,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "2.2.7" +version = "2.2.8" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4357,7 +4357,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "2.2.7" +version = "2.2.8" dependencies = [ "anstyle", "arboard", @@ -4411,11 +4411,11 @@ dependencies = [ [[package]] name = "rustynes-gfx-shaders" -version = "2.2.7" +version = "2.2.8" [[package]] name = "rustynes-hdpack" -version = "2.2.7" +version = "2.2.8" dependencies = [ "lewton", "png", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "2.2.7" +version = "2.2.8" dependencies = [ "bytemuck", "cpal", @@ -4440,7 +4440,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "2.2.7" +version = "2.2.8" dependencies = [ "libc", "rust-libretro", @@ -4449,7 +4449,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "2.2.7" +version = "2.2.8" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4461,7 +4461,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "2.2.7" +version = "2.2.8" dependencies = [ "rustynes-core", "rustynes-hdpack", @@ -4476,7 +4476,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "2.2.7" +version = "2.2.8" dependencies = [ "futures-util", "js-sys", @@ -4492,7 +4492,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "2.2.7" +version = "2.2.8" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4504,14 +4504,14 @@ dependencies = [ [[package]] name = "rustynes-ra" -version = "2.2.7" +version = "2.2.8" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "2.2.7" +version = "2.2.8" dependencies = [ "mlua", "piccolo", @@ -4522,7 +4522,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "2.2.7" +version = "2.2.8" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index 9e66b87b..a8c8d292 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ members = [ default-members = ["crates/rustynes-libretro"] [workspace.package] -version = "2.2.7" +version = "2.2.8" edition = "2024" rust-version = "1.96" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 96aaf1de..05a745c2 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,7 +795,12 @@ and the Material-for-MkDocs documentation handbook at ## Current Release -RustyNES's current release is **v2.2.7 "Timbre II"**, an **expansion-audio fidelity** +RustyNES's current release is **v2.2.8 "Aperture II"**, a **presentation-fidelity** +release: gamma-correct scanlines (linear-light darkening + a WebGL2 gamma fix) and a +sharper Gaussian scanline profile for crisp vertical boundaries. Presentation-only — +the pre-shader framebuffer is byte-identical (**AccuracyCoin 141/141**) and the shipped +native default is unchanged; the shader/appearance changes await on-display + browser +visual verification. It builds on **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 @@ -1042,7 +1047,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.7}, + version = {2.2.8}, 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-android/src/gfx.rs b/crates/rustynes-android/src/gfx.rs index 03955b39..ac0106e7 100644 --- a/crates/rustynes-android/src/gfx.rs +++ b/crates/rustynes-android/src/gfx.rs @@ -39,7 +39,8 @@ struct Uniforms { crop: [f32; 4], // CRT: (scanline, mask, _, _). NTSC: (saturation, sharpness, tint, phase). params: [f32; 4], - // NTSC only: x = PAL mode. Padding for the CRT shader (which reads 12 floats). + // CRT (v2.2.8): x = scanline sharpness, y = gamma-linearize flag. + // LMP-NTSC: x = PAL mode. Bisqwit: picture knobs. aux: [f32; 4], } @@ -441,10 +442,23 @@ impl AndroidGfx { (1.0, screen_aspect / IMG_ASPECT) // letterbox }; // For CRT/scanline/LMP-NTSC the `params` come straight from the per-filter - // sliders (aux unused). For Bisqwit (filter 4) `params.x` is the per-frame - // videoPhase and the picture knobs (contrast/sat/bright/hue) ride in `aux`. + // sliders. For Bisqwit (filter 4) `params.x` is the per-frame videoPhase and + // the picture knobs (contrast/sat/bright/hue) ride in `aux`. + // + // v2.2.8 "Aperture II": the CRT/scanline pass (filters 1, 2) now reads + // `aux.x` = scanline sharpness and `aux.y` = a gamma-linearize flag (1 on a + // plain UNORM surface so the shader keeps the darkening in linear light; 0 on + // an sRGB surface where the sampler/surface already convert). Filters 0 (none) + // and 3 (LMP-NTSC) keep `aux = 0` (LMP-NTSC's PAL flag rides its own path). + let crt_linearize = if self.config.format.is_srgb() { + 0.0 + } else { + 1.0 + }; let (params, aux) = if self.filter == 4 { ([f32::from(self.ntsc_phase), 0.0, 0.0, 0.0], self.params) + } else if self.filter == 1 || self.filter == 2 { + (self.params, [0.5, crt_linearize, 0.0, 0.0]) } else { (self.params, [0.0, 0.0, 0.0, 0.0]) }; diff --git a/crates/rustynes-frontend/src/crt.rs b/crates/rustynes-frontend/src/crt.rs index f10e7ed6..66f36a58 100644 --- a/crates/rustynes-frontend/src/crt.rs +++ b/crates/rustynes-frontend/src/crt.rs @@ -122,6 +122,16 @@ pub struct CrtFilter { scanline: f32, /// Aperture-mask intensity (fixed-subtle for the MVP). mask: f32, + /// v2.2.8 "Aperture II": scanline beam sharpness (aux.x, 0 = the original soft + /// parabola .. 1 = a crisp Gaussian beam for sharp vertical boundaries). Only + /// visible when `scanline > 0`. + sharpness: f32, + /// v2.2.8: gamma round-trip flag (aux.y). `1.0` when the surface is a plain + /// UNORM (e.g. WebGL2) so the shader must sRGB-decode/encode around the + /// scanline+mask darkening to keep it in linear light; `0.0` on an sRGB + /// surface where the sampler + surface already handle the gamma conversion + /// (so the shipped native output stays byte-identical). + linearize: f32, } impl CrtFilter { @@ -216,13 +226,19 @@ impl CrtFilter { }); let scanline = scanline.clamp(0.0, 1.0); let mask = 0.10; // subtle fixed grille for the MVP. + // v2.2.8: crisper scanlines by default (only visible when scanline > 0), and + // the gamma round-trip flag derived from the surface's color space (sRGB -> + // 0 = leave linear; plain UNORM/WebGL2 -> 1 = decode/encode in the shader). + let sharpness = 0.5; + let linearize = if surface_format.is_srgb() { 0.0 } else { 1.0 }; let uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("crt-uniforms"), - // rect (identity letterbox) + crop (none) + params (scanline, mask). + // rect + crop + params (scanline, mask, rows, _) + aux (sharpness, linearize, _, _). contents: bytemuck::cast_slice(&[ 1.0f32, 1.0, 0.0, 0.0, // rect 1.0, 0.0, 1.0, 0.0, // crop (v-scale, v-off, u-scale, u-off) scanline, mask, 0.0, 0.0, // params + sharpness, linearize, 0.0, 0.0, // aux ]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); @@ -251,6 +267,8 @@ impl CrtFilter { bind_group, scanline, mask, + sharpness, + linearize, } } @@ -274,7 +292,7 @@ impl CrtFilter { par_correction: bool, overscan: crate::config::Overscan, ) { - // rect (4) + crop (4) from the shared helper, then the CRT params (4). + // rect (4) + crop (4) from the shared helper, then params (4) + aux (4). let lb = crate::gfx::letterbox_uniform(width, height, par_correction, overscan); let uniform = [ lb[0], @@ -289,6 +307,10 @@ impl CrtFilter { self.mask, 0.0, 0.0, + self.sharpness, + self.linearize, + 0.0, + 0.0, ]; queue.write_buffer(&self.uniforms, 0, bytemuck::cast_slice(&uniform)); diff --git a/crates/rustynes-gfx-shaders/src/lib.rs b/crates/rustynes-gfx-shaders/src/lib.rs index 84c3f451..b3d40777 100644 --- a/crates/rustynes-gfx-shaders/src/lib.rs +++ b/crates/rustynes-gfx-shaders/src/lib.rs @@ -24,15 +24,31 @@ /// 2. **Aperture mask** — a subtle RGB phosphor grille keyed off the output column /// (`params.y` = intensity), with a small brightness compensation. /// -/// Uniform layout (12 `f32`): `rect` (letterbox: x,y = scale, z,w = offset), +/// Uniform layout (16 `f32`): `rect` (letterbox: x,y = scale, z,w = offset), /// `crop` (overscan: x = v-scale, y = v-offset, z = u-scale, w = u-offset), -/// `params` (x = scanline, y = mask, z,w unused). Setting `params` to (0,0) and -/// `crop` to (1,0,1,0) yields a plain letterboxed blit. +/// `params` (x = scanline, y = mask, z = source rows, w unused), and `aux` +/// (v2.2.8 "Aperture II": x = scanline sharpness 0..1, y = linearize flag, +/// z,w unused). Setting `params` to (0,0,0,0) and `crop` to (1,0,1,0) yields a +/// plain letterboxed blit; `aux = (0,0,0,0)` preserves the pre-v2.2.8 look +/// exactly (soft parabola, no explicit gamma round-trip). +/// +/// **Gamma (aux.y).** The scanline + mask *darkening* is perceptually correct +/// only in linear light. On an sRGB-format input texture + surface (native) the +/// sampler already decodes to linear and the surface re-encodes on write, so +/// `aux.y = 0` (the math is already linear — leave it). On a plain UNORM path +/// (e.g. WebGL2, which does neither) set `aux.y = 1`: the shader sRGB-decodes on +/// read and re-encodes before returning, so the darkening happens in linear +/// light there too. +/// +/// **Sharpness (aux.x).** `0` = the original soft parabola (unchanged); rising +/// toward `1` blends to a narrow Gaussian beam so the vertical boundaries +/// between source rows are crisp instead of blurred by the linear sampler. pub const CRT_WGSL: &str = r" struct Uniforms { rect: vec4, // letterbox transform (same shape + math as gfx.wgsl) crop: vec4, // overscan crop: x=v-scale, y=v-offset, z=u-scale, w=u-offset - params: vec4, // x = scanline intensity, y = mask intensity, z,w unused + params: vec4, // x = scanline intensity, y = mask intensity, z = rows, w unused + aux: vec4, // x = scanline sharpness (0..1), y = linearize flag, z,w unused }; @group(0) @binding(0) var nes_tex: texture_2d; @@ -65,6 +81,22 @@ fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut { return out; } +// The exact IEC 61966-2-1 sRGB transfer pair (piecewise: a linear segment below +// the breakpoint, a 2.4-exponent power segment above), NOT a plain pow(2.2) +// approximation. This is what a hardware sRGB texture/surface applies, so the +// WebGL2 aux.y = 1 round-trip matches the native sRGB path bit-for-bit instead of +// drifting in the shadows where the two curves diverge most. +fn srgb_to_linear(c: vec3) -> vec3 { + let lo = c / 12.92; + let hi = pow((c + vec3(0.055)) / 1.055, vec3(2.4)); + return select(hi, lo, c <= vec3(0.04045)); +} +fn linear_to_srgb(c: vec3) -> vec3 { + let lo = c * 12.92; + let hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - vec3(0.055); + return select(hi, lo, c <= vec3(0.0031308)); +} + @fragment fn fs_main(in: VsOut) -> @location(0) vec4 { // Letterbox bars -> black. @@ -75,21 +107,45 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let suv = vec2(in.uv.x * u.crop.z + u.crop.w, in.uv.y * u.crop.x + u.crop.y); var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + // v2.2.8 'Aperture II': do the scanline + mask *darkening* in LINEAR light so a + // scanline valley at 50% is 50% of the LINEAR luminance (perceptually correct), + // not 50% of the gamma-encoded value. aux.y = 1 (a plain UNORM path, e.g. WebGL2) + // means the sampler handed us gamma-encoded values and the surface will not + // encode on write -> decode here, re-encode before returning. aux.y = 0 (an + // sRGB texture + surface, e.g. native) means the sampler already decoded to + // linear and the surface re-encodes on write, so the math is already in linear + // light -> skip the round-trip and stay byte-identical to the pre-v2.2.8 output. + let linearize = u.aux.y > 0.5; + if (linearize) { + rgb = srgb_to_linear(rgb); + } + let scan_amt = u.params.x; let mask_amt = u.params.y; // Scanlines in source-row space. The row count is params.z (so the host can // expose a 'number of scanlines' control); fall back to the NES's 240 rows when // unset (params.z < 1, e.g. the desktop, which leaves it 0 -> unchanged). - // Parabolic profile: 1.0 at the row centre, (1 - scan_amt) at the row boundary. + // + // aux.x = sharpness (0..1). At 0 this is EXACTLY the original soft parabola + // (1.0 at the row centre, (1 - scan_amt) at the boundary), so aux = 0 preserves + // the pre-v2.2.8 look. Rising toward 1 blends to a narrow Gaussian beam so the + // vertical boundaries between source rows are crisp instead of blurred by the + // linear sampler -- the sharper scanlines the NESdev-forum feedback asked for. let rows = select(240.0, u.params.z, u.params.z >= 1.0); let src_y = suv.y * rows; let d = fract(src_y) - 0.5; - let scan = (1.0 - scan_amt) + scan_amt * (1.0 - 4.0 * d * d); + let sharpness = clamp(u.aux.x, 0.0, 1.0); + let parabola = 1.0 - 4.0 * d * d; + let sigma = mix(0.30, 0.10, sharpness); + let gaussian = exp(-(d * d) / (2.0 * sigma * sigma)); + let profile = mix(parabola, gaussian, sharpness); + let scan = (1.0 - scan_amt) + scan_amt * profile; rgb = rgb * scan; // Aperture grille: tint output columns in an R/G/B triad. Each channel is - // attenuated on the two columns where it is not the dominant phosphor. + // attenuated on the two columns where it is not the dominant phosphor. (In + // linear light now, so the tint is perceptually even.) let col = i32(floor(in.pos.x)) % 3; var mask = vec3(1.0 - mask_amt, 1.0 - mask_amt, 1.0 - mask_amt); if (col == 0) { @@ -106,7 +162,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let comp = 1.0 + 0.5 * (scan_amt + mask_amt); rgb = rgb * comp; - return vec4(clamp(rgb, vec3(0.0), vec3(1.0)), 1.0); + rgb = clamp(rgb, vec3(0.0), vec3(1.0)); + if (linearize) { + rgb = linear_to_srgb(rgb); + } + return vec4(rgb, 1.0); } "; diff --git a/crates/rustynes-ios/src/gfx_metal.rs b/crates/rustynes-ios/src/gfx_metal.rs index be73f315..53437240 100644 --- a/crates/rustynes-ios/src/gfx_metal.rs +++ b/crates/rustynes-ios/src/gfx_metal.rs @@ -489,11 +489,27 @@ impl MetalGfx { fn write_uniforms(&self) { let (sx, sy) = self.letterbox_scale(); - // For CRT/scanline/LMP-NTSC the `params` come straight from the per-filter - // sliders (aux unused). For Bisqwit (filter 4) `params.x` is the per-frame - // videoPhase and the picture knobs ride in `aux`. + // The v2.2.8 CRT/scanline uniform carries two extra knobs in `aux`: + // `aux.x` = scanline sharpness (0.5 blends the soft parabola toward a + // narrower Gaussian beam) and `aux.y` = a gamma-linearize flag — 1 on a + // non-sRGB surface where the sampler won't sRGB-decode for us, else 0. The + // native Metal surface is sRGB, so `crt_linearize` is 0 here and the CRT / + // scanline output stays byte-identical to pre-v2.2.8. This mirrors the + // desktop (`crt.rs`) and Android (`gfx.rs`) hosts so all three platforms + // get the same gamma-correct, sharper scanlines. + let crt_linearize = if self.config.format.is_srgb() { + 0.0 + } else { + 1.0 + }; + // For CRT (2) / scanline (1) the `params` come from the per-filter sliders + // and `aux` carries the sharpness + gamma flag. For Bisqwit (filter 4) + // `params.x` is the per-frame videoPhase and the picture knobs ride in + // `aux`. None (0) and LMP-NTSC (3) leave `aux = 0`. let (params, aux) = if self.filter == 4 { ([f32::from(self.ntsc_phase), 0.0, 0.0, 0.0], self.params) + } else if self.filter == 1 || self.filter == 2 { + (self.params, [0.5, crt_linearize, 0.0, 0.0]) } else { (self.params, [0.0, 0.0, 0.0, 0.0]) }; diff --git a/docs/STATUS.md b/docs/STATUS.md index 0e930a01..9f2eb8b1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,10 @@ # RustyNES — Project Status Matrix -> **Current release: v2.2.7** (2026-08-04) — **"Timbre II"**, an **expansion-audio +> **Current release: v2.2.8** (2026-08-04) — **"Aperture II"**, a **presentation-fidelity** +> release (gamma-correct scanlines in linear light + a WebGL2 gamma fix + a sharper +> Gaussian scanline profile; presentation-only, so the pre-shader framebuffer + +> AccuracyCoin 141/141 are byte-identical and the shipped native default is unchanged — +> visual verification pending). Built on **v2.2.7** — **"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; diff --git a/docs/crt-composite.md b/docs/crt-composite.md index 935781b6..ad21cce0 100644 --- a/docs/crt-composite.md +++ b/docs/crt-composite.md @@ -46,3 +46,29 @@ The fixed render order is: stack → CRT → Bisqwit → NTSC → direct blit The shared WGSL lives in `crates/rustynes-gfx-shaders`. See [`frontend.md`](frontend.md) for the full pipeline, the CRT / scanline passes, and the preset / import machinery. + +### Base scanline pass — gamma + sharpness (v2.2.8 "Aperture II") + +The base CRT/scanline pass (`CRT_WGSL`) reads a 16-float uniform +(`rect + crop + params + aux`). Two `aux` slots were added in v2.2.8: + +- **`aux.y` — gamma round-trip.** The scanline + aperture-mask *darkening* must + happen in **linear light** to be perceptually correct. On the native path (an + sRGB texture + sRGB surface) the sampler decodes and the surface re-encodes, so + the math is already linear and `aux.y = 0` (the shipped native output is + byte-identical to pre-v2.2.8). On a plain UNORM path (**WebGL2**, which does + neither) the host sets `aux.y = 1` and the shader sRGB-decodes on read / + re-encodes before output — fixing a browser-only gamma error. The decode/encode + use the **exact IEC 61966-2-1 piecewise sRGB transfer** (`srgb_to_linear` / + `linear_to_srgb` in `CRT_WGSL`: a linear segment below `0.04045` / `0.0031308`, + a 2.4-exponent power segment above), i.e. the same curve a hardware sRGB surface + applies — not a `pow(2.2)` approximation — so the WebGL2 path matches the native + sRGB path where the two curves would otherwise diverge (the shadows). +- **`aux.x` — scanline sharpness (0..1, default 0.5).** The profile blends from + the original soft parabola (0) to a narrow Gaussian beam (1) for crisp vertical + row boundaries instead of the linear-sampler blur. `aux.x = 0` reproduces the + pre-v2.2.8 profile exactly; it is only visible when scanlines are enabled. + +The advanced CRT stacks (CRT-Royale / Guest / Megatron) were already gamma-correct +via their own `gamma_in`/`gamma_out` knobs and are unchanged. Both the desktop +(`crt.rs`) and Android (`gfx.rs`) hosts feed the same `aux`.