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 @@
-

+


@@ -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`.