diff --git a/.github/release-notes/v2.0.2.md b/.github/release-notes/v2.0.2.md index 943aa7ca..f02b3502 100644 --- a/.github/release-notes/v2.0.2.md +++ b/.github/release-notes/v2.0.2.md @@ -21,7 +21,7 @@ A12 / MMC3 notification stays on the **intended** (un-spliced) fetch address at The campaign (`docs/audit/v2.0.2-octal-latch-campaign-2026-07-08.md`) corrected two premises v2.0.1 carried into ADR 0030: -1. **Mesen2 does NOT pass these two tests.** The per-cycle bus cross-diff proved the vendored Mesen2 build reads `0x0A` (corruption not reproduced) on both — so ADR 0030's prior "Option 2 = proven-correct Mesen2 recipe" premise was false. The correct definitive oracle is **TriCNES** (`ref-proj/TriCNES/Emulator.cs`, MIT, commit `9199870` — the **AccuracyCoin author's own emulator**), which models the multiplexed AD/A bus + octal latch at transistor level and does drive `$2F19` / `$0FFF`. +1. **Mesen2 does NOT pass these two tests.** The per-cycle bus cross-diff proved the vendored Mesen2 build reads `0x0A` (corruption not reproduced) on both — so ADR 0030's prior "Option 2 = proven-correct Mesen2 recipe" premise was false. The correct definitive oracle is **TriCNES** (`Emulator.cs`, MIT, commit `9199870`; at the time read from a local reference clone, since removed — the source is now vendored in-repo at `crates/rustynes-test-harness/golden/tricnes/tricnes-full-src/` — the **AccuracyCoin author's own emulator**), which models the multiplexed AD/A bus + octal latch at the pin/sub-cycle level and does drive `$2F19` / `$0FFF`. *(Corrected in v2.3.0: TriCNES is a cycle-accurate C# emulator with a detailed sub-cycle state machine, not a transistor-level one — "transistor level" properly describes die-derived simulations such as `Visual2C02` / `phantom2c02`.)* 2. **A whole-dot port suffices.** The full 2-cycle-ALE fetch refactor (v2.0.1's Option 1) was **not** required; porting TriCNES's octal latch at RustyNES's whole-dot cadence, behind the existing flag, reaches 141/141 flag-on. ## Verification — the honesty bar diff --git a/.github/release-notes/v2.2.5.md b/.github/release-notes/v2.2.5.md index 9014cc44..09294276 100644 --- a/.github/release-notes/v2.2.5.md +++ b/.github/release-notes/v2.2.5.md @@ -1,107 +1,47 @@ # RustyNES v2.2.5 — "Colophon" (provenance, licensing, and documentation integrity) -A **provenance, licensing, and documentation-integrity** release. It was prompted -by community review (NESdev forums) of the project's licensing and AI-assisted -origins, and it corrects how RustyNES *describes its own provenance* — in source -comments, in `NOTICE`, and in the docs — so the record matches what the code -actually is. - -**Zero emulation-core behavior changes.** The deterministic `#![no_std]` chip stack, -save-state / TAS / netplay formats, and every golden vector are byte-identical to -v2.2.4 by construction, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is -0-diff. This was re-verified after every change (AccuracyCoin full + run-ahead, -the nestest golden log, the dual-path differential net, and the snapshot-schema -audit), and the whole tree passes `cargo fmt`, `clippy -D warnings` (workspace + -feature combos), `rustdoc -D warnings`, markdownlint, and the `no_std` -cross-compile. +> **⚠️ SUPERSEDED — this release's central provenance claim was wrong and has been withdrawn.** The statement below that *"No GPL-licensed emulator source is incorporated into RustyNES"* did not survive further review. **v2.2.9** established that RustyNES **is** a derivative work of GPL-licensed emulators — code derived from Mesen2 (GPL-3.0-or-later) and, for several mappers and the FDS drive model, from puNES, FCEUX and Nestopia UE (GPL-2.0-or-later) — and **relicensed the project from MIT/Apache-2.0 to GPL-3.0-or-later** (ADR 0036). The reviewer who pushed back was right and this note was wrong. The current record is `NOTICE` and [`docs/originality-and-provenance.md`](../../docs/originality-and-provenance.md); the v2.2.9 release notes carry the correction. This text is retained unedited below as the historical account of what was believed at the time — the remedy for a bad provenance claim is to correct it in the open, never to quietly rewrite it. + +A **provenance, licensing, and documentation-integrity** release. It was prompted by community review (NESdev forums) of the project's licensing and AI-assisted origins, and it corrects how RustyNES *describes its own provenance* — in source comments, in `NOTICE`, and in the docs — so the record matches what the code actually is. + +**Zero emulation-core behavior changes.** The deterministic `#![no_std]` chip stack, save-state / TAS / netplay formats, and every golden vector are byte-identical to v2.2.4 by construction, so **AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff. This was re-verified after every change (AccuracyCoin full + run-ahead, the nestest golden log, the dual-path differential net, and the snapshot-schema audit), and the whole tree passes `cargo fmt`, `clippy -D warnings` (workspace + feature combos), `rustdoc -D warnings`, markdownlint, and the `no_std` cross-compile. ## Why this release exists -A reviewer pointed out — correctly — that a number of in-source comments described -implementations as "ports" of other emulators, including GPL-licensed ones, and -that the project's AI-assisted nature was not disclosed. Rather than argue the -point, we audited the entire tree and fixed it. The short version: +A reviewer pointed out — correctly — that a number of in-source comments described implementations as "ports" of other emulators, including GPL-licensed ones, and that the project's AI-assisted nature was not disclosed. Rather than argue the point, we audited the entire tree and fixed it. The short version: -- The chip, mapper, and peripheral **behaviors** are implemented from public - hardware documentation (the NESdev wiki, published datasheets, the documented - 6502 unofficial-opcode behavior) and pinned to public test ROMs. Where a - reference emulator was consulted, it was used as a **behavioral oracle** — to - observe and cross-check documented behavior — not as a source of copied code. -- Some comments had mischaracterized that relationship as "ported from X." Those - were reworded to state what actually happened. **No GPL-licensed emulator source - is incorporated into RustyNES.** +- The chip, mapper, and peripheral **behaviors** are implemented from public hardware documentation (the NESdev wiki, published datasheets, the documented 6502 unofficial-opcode behavior) and pinned to public test ROMs. Where a reference emulator was consulted, it was used as a **behavioral oracle** — to observe and cross-check documented behavior — not as a source of copied code. +- Some comments had mischaracterized that relationship as "ported from X." Those were reworded to state what actually happened. **No GPL-licensed emulator source is incorporated into RustyNES.** ## Licensing & provenance -- **In-source "port" comments corrected** across the CPU (unstable-store opcodes), - PPU (sprite-evaluation and OAM models), APU, and numerous mapper register - decoders — reframed from "ported from Mesen2 / puNES" (GPLv3 / GPLv2) to - independent implementations of publicly-documented hardware behavior, - cross-checked against reference emulators as oracles. +- **In-source "port" comments corrected** across the CPU (unstable-store opcodes), PPU (sprite-evaluation and OAM models), APU, and numerous mapper register decoders — reframed from "ported from Mesen2 / puNES" (GPLv3 / GPLv2) to independent implementations of publicly-documented hardware behavior, cross-checked against reference emulators as oracles. - **`NOTICE` rewritten** to: - - disclose the behavioral-oracle use of GPL-licensed emulators - (Mesen2/MesenCE, higan, **GeraNES**, ares, FCEUX, Nestopia UE, puNES) with **no - code incorporated**; - - attribute the genuinely incorporated permissive components — **emu2413** - (Mitsutaka Okazaki, MIT), **TriCNES** (Chris Siebert, MIT), and **rcheevos** - (RetroAchievements.org, MIT) — with their copyright notices and the MIT text; - - attribute the bundled fonts (**Font Awesome**; **Press Start 2P** / OFL) and - the bundled **test ROMs** (AccuracyCoin MIT, Damian Yerrick Holy Mapperel zlib, - blargg / kevtris public domain); - - credit the CRT-shader / NTSC-filter **visual influences** as independent - reimplementations. -- **GeraNES (GPL-3.0-only) disclosed** — it was cited as a reference across ~58 - files but was previously absent from `NOTICE`. -- **CRT shaders and NTSC filters** (`crt_royale`, `crt_guest`, `megatron`, the - Bisqwit and EMMIR NTSC filters) were reviewed at source level. Each is a - single-pass shader built on RustyNES's own uniform/pipeline conventions and is - structurally incompatible with being a translation of the upstream *multi-pass* - shader source; copyright protects code expression, not a visual look or a - rendering technique, so these are independent reimplementations. Comments were - reworded from "port / condensation of X" accordingly, and the one comment - claiming tables were "ported verbatim from Bisqwit's C" was corrected — those - tables encode the two-level NES composite signal documented at the NESdev wiki. -- **`blip.rs`** no longer mislabels `blip_buf` as BSD/MIT (it is LGPL-2.1+); the - file is an independent band-limited-step (BLEP) implementation and now says so. + - disclose the behavioral-oracle use of GPL-licensed emulators (Mesen2/MesenCE, higan, **GeraNES**, ares, FCEUX, Nestopia UE, puNES) with **no code incorporated**; + - attribute the genuinely incorporated permissive components — **emu2413** (Mitsutaka Okazaki, MIT), **TriCNES** (Chris Siebert, MIT), and **rcheevos** (RetroAchievements.org, MIT) — with their copyright notices and the MIT text; + - attribute the bundled fonts (**Font Awesome**; **Press Start 2P** / OFL) and the bundled **test ROMs** (AccuracyCoin MIT, Damian Yerrick Holy Mapperel zlib, blargg / kevtris public domain); + - credit the CRT-shader / NTSC-filter **visual influences** as independent reimplementations. +- **GeraNES (GPL-3.0-only) disclosed** — it was cited as a reference across ~58 files but was previously absent from `NOTICE`. +- **CRT shaders and NTSC filters** (`crt_royale`, `crt_guest`, `megatron`, the Bisqwit and EMMIR NTSC filters) were reviewed at source level. Each is a single-pass shader built on RustyNES's own uniform/pipeline conventions and is structurally incompatible with being a translation of the upstream *multi-pass* shader source; copyright protects code expression, not a visual look or a rendering technique, so these are independent reimplementations. Comments were reworded from "port / condensation of X" accordingly, and the one comment claiming tables were "ported verbatim from Bisqwit's C" was corrected — those tables encode the two-level NES composite signal documented at the NESdev wiki. +- **`blip.rs`** no longer mislabels `blip_buf` as BSD/MIT (it is LGPL-2.1+); the file is an independent band-limited-step (BLEP) implementation and now says so. ## Documentation -- **New `docs/originality-and-provenance.md`** — a candid account of where RustyNES - advances, diverges from, or independently re-derives NES emulation technique - (the one-clock timebase, the transistor-literal octal-latch fetch, the - machine-checked accuracy-honesty gates, the determinism contract, measured-and- - rejected optimizations), the development timeline, an oracle-versus-port - classification of every reference, and the full license posture — written to be - honest rather than triumphal, including that the project is heavily AI-assisted. -- **README** — added an AI-assistance disclosure; removed a comparison graphic that - contained inaccurate details; corrected a mislabeled "sub-cycle accuracy in - action" screenshot caption (it was an early-development image); toned down - overstated language; and synced the Acknowledgments with `NOTICE`. -- **`tests/roms/LICENSES.md`** — fixed a false exclusion claim (four Holy Mapperel - mapper ROMs stated as excluded are in fact committed), a stale crate path, and - the AccuracyCoin sub-test count, and added blanket coverage for the committed - directories not individually tabulated (328 committed `.nes` total, none - commercial). +- **New `docs/originality-and-provenance.md`** — a candid account of where RustyNES advances, diverges from, or independently re-derives NES emulation technique (the one-clock timebase, the pin-level octal-latch fetch, the machine-checked accuracy-honesty gates, the determinism contract, measured-and- rejected optimizations), the development timeline, an oracle-versus-port classification of every reference, and the full license posture — written to be honest rather than triumphal, including that the project is heavily AI-assisted. +- **README** — added an AI-assistance disclosure; removed a comparison graphic that contained inaccurate details; corrected a mislabeled "sub-cycle accuracy in action" screenshot caption (it was an early-development image); toned down overstated language; and synced the Acknowledgments with `NOTICE`. +- **`tests/roms/LICENSES.md`** — fixed a false exclusion claim (four Holy Mapperel mapper ROMs stated as excluded are in fact committed), a stale crate path, and the AccuracyCoin sub-test count, and added blanket coverage for the committed directories not individually tabulated (328 committed `.nes` total, none commercial). ## Assets -- **Press Start 2P OFL text** added to the Android app assets. The font shipped in - the Android app without the SIL Open Font License text that OFL 1.1 requires - travel with the font; the desktop and iOS builds already carried it. +- **Press Start 2P OFL text** added to the Android app assets. The font shipped in the Android app without the SIL Open Font License text that OFL 1.1 requires travel with the font; the desktop and iOS builds already carried it. ## Compatibility -No format, save-state, or behavior changes. `.rns` save-states, `.rnm` movies, and -netplay replays are byte-identical to v2.2.4. The libretro core's -`display_version` advances to `v2.2.5`; there are no other functional changes to -the RetroArch integration. +No format, save-state, or behavior changes. `.rns` save-states, `.rnm` movies, and netplay replays are byte-identical to v2.2.4. The libretro core's `display_version` advances to `v2.2.5`; there are no other functional changes to the RetroArch integration. ## Verification - **AccuracyCoin: 141/141 (100.00%)** — full suite and through run-ahead. - **nestest: 0-diff** against the Nintendulator golden log. - **Dual-path differential net** and **snapshot-schema audit** green. -- `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings` - (plus the `retroachievements` / `scripting` / `hd-pack` feature combos), - `RUSTDOCFLAGS="-D warnings" cargo doc`, markdownlint, and the `no_std` - `thumbv7em-none-eabihf` cross-compile all pass. +- `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings` (plus the `retroachievements` / `scripting` / `hd-pack` feature combos), `RUSTDOCFLAGS="-D warnings" cargo doc`, markdownlint, and the `no_std` `thumbv7em-none-eabihf` cross-compile all pass. diff --git a/.github/release-notes/v2.3.0.md b/.github/release-notes/v2.3.0.md new file mode 100644 index 00000000..d3144a2f --- /dev/null +++ b/.github/release-notes/v2.3.0.md @@ -0,0 +1,57 @@ +# RustyNES v2.3.0 — "Datum II" (PPU-accuracy capstone + true multi-viewport tool windows) + +The capstone that closes the **v2.2.6 → v2.3.0** NESdev-remediation line. Both remaining forum-reported accuracy concerns were investigated under reproduce-before-fixing discipline and turned out to be **already correct** — so the substance of this release landed elsewhere: tool panels that open as *real OS windows*, and a frame-pacing defect that had been quietly degrading every session with a debugger panel open. + +**AccuracyCoin holds 141/141 (100.00%)** and nestest is 0-diff — and the gate now enforces that as an *exact* count rather than a 60% floor. + +--- + +## Tool windows are now real OS windows + +v2.2.9 shipped a detach affordance with an honest caveat: on a single-viewport `egui_winit` integration, `show_viewport_immediate` merely **embedded** the panel inside the main window, so the Windows-10 "every tool window is trapped in the main window" report was **not** actually resolved. It is now. + +The new `crates/rustynes-frontend/src/detached.rs` gives each detached panel a real winit window with its own egui `Context` / `State` / `Renderer` and wgpu `Surface`, sharing the main device and queue. **It needs no `unsafe`** — unlike egui's native immediate-viewport path, whose re-entrant callback must create a window from an `&ActiveEventLoop` that is only valid during event dispatch (which is why `eframe` erases that lifetime into a `'static` thread-local). Instead each window renders on its *own* `RedrawRequested`, in its own stack frame, where the panel's `&mut Nes` borrow is freshly re-acquired; a thread-local render target makes the shared panel dispatch paint exactly one panel per window. + +**Every tool window is detachable now.** The nine panels that predated the shared helper — CPU, Cartridge Info / Header, Lua Script, BasicBot, Input bindings, TAStudio, Settings, Netplay, RetroAchievements — were converted, and BasicBot's bespoke embed-viewport implementation was retired in favour of the shared path. Detached windows inherit the main window's theme, UI zoom and locale, and open at the size their docked window actually had (captured at the click, not from a static table). Detach / Reattach use real Font Awesome glyphs; the previous `⧉` existed in neither the icon font nor the default font and silently rendered as nothing. wasm is single-canvas and keeps panels docked, unchanged. + +*Known limitation:* egui cannot place a control in a native window's title bar — that bar belongs to the window manager — so **Detach** is a small top-row control rather than sitting beside the close button. Reattach works from the detached window's own button and from its OS close button. + +## The frame-pacing fix (stutter / high p99) + +Profiling the detach work surfaced a root cause that **predated it**. The overlay-visible render branch held the emulator mutex — in its own comment's words — *"until after the present call"*. Inside `Gfx::render_with_overlay`, the **blocking** `Surface::get_current_texture` runs *before* the egui pass. So on every frame with the debugger overlay or any `nes`-reading tool panel open, the winit thread owned the lock across a swapchain wait (up to a full display refresh), the entire egui build, the encode, the submit **and** the present — while the emulation thread sat parked on `emu.lock()`, unable to produce a frame. Each detached window would have added another such acquisition, which is why it worsened per window. + +`DebuggerOverlay::render_shell` is now split into `run_shell_ui` (needs `&mut Nes`; the caller holds the lock) and `paint_shell` (pure GPU work, lock released). The framebuffer already came from `present_staging`, so `nes_for_render` was the *only* reason to hold the lock at all. `DetachedManager` is split the same way. + +Separately, `pace_frames` answered "is a ROM loaded?" by taking the emulator mutex on **every** `about_to_wait` iteration — a tight spin in the wall-clock regime — which could block the UI thread for a full produce (~4 ms) each time. It now reads the lock-free `EmuControl::has_rom` atomic, falling back to the locked read only when no emulation thread exists. + +Detached panels also repaint on **per-panel tiers** — Live (60 Hz) for continuously-changing state, Throttled (~10 Hz) for status, and interaction-only for static panels like Cheats and ROM Info — so a wall of open tool windows costs almost nothing while idle. + +## PPU: −5.13% frame cost, byte-identical + +A measure-first campaign (`v2.3.0 P1` in `docs/performance.md`) that began by **discarding its own baseline**: the first profile ran on a contended machine at 39% criterion outliers; re-measured quiet it reported 2%, which is the only reason the subsequent deltas mean anything. + +`perf annotate` — the same instrument that redirected the v2.2.3 P4 investigation — showed the cost was not the state machines. In `tick_sprite_eval_per_dot` the two hottest instructions in the entire body were its own `push`/`ret`: **pure call overhead** across 89,342 calls per frame, which LLVM had declined to inline. And `tick_oam_bus` derived `sprite_height` and the y-test reference *before* its dot-0 early-out, computing and discarding both. + +| Workload | before | after | change | +|---|---|---|---| +| `nes_run_frame_nestest_fast` | 3.8987 ms | **3.7830 ms** | **−5.13%** (95% CI −5.60…−4.60, p = 0.00) | +| `nes_run_frame_flowing_palette_fast` | 2.7314 ms | **2.6354 ms** | **−3.51%** (95% CI −3.93…−3.10, p = 0.00) | + +Both clear the project's **>3%** adoption bar on **both** workloads. Byte-identity was verified, not asserted: AccuracyCoin **141/141** through the exact-count gate, the nestest golden CPU log **0-diff**, and `rustynes-ppu` **91/91** unit tests. + +## Accuracy: both forum items verified already-correct + +The **SMB left-edge** column and the **Rad Racer hybrid-address** render were both investigated by reproducing first — and both were found *already correct* in the shipped build, resolved earlier by the v2.0.0 "Timebase" rewrite and the v2.0.3 2-cycle-ALE promotion, predating the report. SMB's leftmost background column renders real content matching the blessed golden; the hybrid-address model passes the authoritative AccuracyCoin "Hybrid Addresses" test and renders Rad Racer's road and horizon cleanly. Disabling the delayed-`CopyV` drops exactly that test to 140/141, which is what the new exact gate now guards. See ADR 0030's v2.3.0 update. + +## Honesty and provenance + +- **The `≤ 2 ms` frame-cost figure is now labeled a design-phase aspiration, not a gate.** It was written before the cycle-accurate core existed, for 2018-era hardware. The core measures ~3.8 ms (~23% of the NTSC budget) and that is knowingly accepted: the dominant costs are work the accuracy model *requires*, and the obvious levers were already measured and **rejected** (`emit_pixel` bounds-check elision and the SIMD blitter both made the shipped default *slower*). Recorded so no contributor optimizes toward it by trading away accuracy. +- **TriCNES is no longer described as "transistor-level."** It is a cycle-accurate C# emulator built on a detailed sub-cycle state machine. "Transistor level" properly denotes die-derived simulations such as `Visual2C02` / `phantom2c02`, which the repository already cites correctly. Corrected in source, `NOTICE`, README, ADR 0030, and the published v2.0.2 / v2.2.5 release notes — it remains the correct oracle for the ALE/hybrid-address tests, on the strength of its sub-cycle model. +- **GeraNES reference comments corrected.** Dangling source-file paths and a quoted C++ line were reworded to state honestly that its source was consulted as a cross-reference confirming nesdev-documented behavior, with **no code copied**. Reviewed two-sided against the upstream source and the nesdev register maps: the coinciding operations are the single correct expression of a documented register layout. That is the maintainers' **assessment offered for expert review, not a certification** — this project does not self-certify license cleanliness, and the residual uncertainties are named in `docs/originality-and-provenance.md` §3. +- **libretro core license declared as `GPLv3`**, the notation mesen / melonDS / bsnes use; the SPDX `GPL-3.0-or-later` stays in the Cargo metadata. + +--- + +## Compatibility + +Save states, movies (`.rnm`), and netplay replays are unchanged from v2.2.x. The deterministic `#![no_std]` chip stack is byte-identical except for the intentional, oracle-gated PPU optimization above, which is byte-identical by construction and verified as such. diff --git a/AGENTS.md b/AGENTS.md index 580102d0..d0bf5fe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ Enforcement lives alongside the prose: `/ref-proj/` is gitignored/`.dockerignore RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. The accuracy bar is Mesen2 / higan / ares: tight lockstep scheduling at PPU-dot resolution on a master-clock-precise timebase, sub-instruction PPU events visible to subsequent CPU code, and a lookup-table non-linear audio mixer with band-limited synthesis. The frontend is pure Rust (`winit` + `wgpu` + `cpal` + `egui`). -**Current release: v2.2.9 "Studio II"** (2026-08-04, a frontend quality-of-life release opening the second half of the v2.2.6 → v2.3.0 NESdev-remediation line — TAStudio piano-roll edits now drive the emulator [`handle_tas_requests` re-seeks the `Nes` after a `SetInput` batch, matching the scripting path], `.bk2` movies play back honoring their `LogKey` column order [`bk2_interop` parses the real column header instead of a fixed order, with parse errors surfaced on the status bar], and tool windows gain a **detach / pop-out** affordance (the shared `detachable_window` helper across 18 panels) [native-only, docked on wasm; **honest scope caveat:** the frontend is currently a single-viewport `egui_winit` integration, so `show_viewport_immediate` renders the panel *embedded in the main window*, NOT yet a separate OS window — so this does not yet fully resolve the Windows-10 trapped-window report; true OS-window detach needs multi-viewport render-loop wiring (`set_embed_viewports(false)` + per-viewport winit windows), tracked as follow-up]; frontend-only, so the deterministic core is untouched and **AccuracyCoin holds 141/141 (100.00%)** with nestest 0-diff), on top of **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. +**Current release: v2.3.0 "Datum II"** (2026-08-05, the capstone that CLOSES the v2.2.6 → v2.3.0 NESdev-remediation line — true multi-viewport OS-window detach for every tool panel, the emulator-lock frame-pacing fix, a −5.1% byte-identical PPU optimization, both remaining forum-reported accuracy items verified already-correct, and the AccuracyCoin gate pinned to an exact 141/141. Built on **v2.2.9 "Studio II"** (2026-08-04, a frontend quality-of-life release that opened the second half of that line — TAStudio piano-roll edits now drive the emulator [`handle_tas_requests` re-seeks the `Nes` after a `SetInput` batch, matching the scripting path], `.bk2` movies play back honoring their `LogKey` column order [`bk2_interop` parses the real column header instead of a fixed order, with parse errors surfaced on the status bar], and tool windows gain a **detach / pop-out** affordance (the shared `detachable_window` helper across 18 panels) [native-only, docked on wasm; **honest scope caveat:** the frontend is currently a single-viewport `egui_winit` integration, so `show_viewport_immediate` renders the panel *embedded in the main window*, NOT yet a separate OS window — so this does not yet fully resolve the Windows-10 trapped-window report; true OS-window detach needs multi-viewport render-loop wiring (`set_embed_viewports(false)` + per-viewport winit windows), tracked as follow-up]; frontend-only, so the deterministic core is untouched and **AccuracyCoin holds 141/141 (100.00%)** with nestest 0-diff), on top of **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. @@ -56,7 +56,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); 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). +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. A **free-app store launch** (Google Play / App Store / AltStore PAL / F-Droid — no ads, tracking, or paid unlock, per ADR 0035) remains a candidate, but it is **no longer tied to v2.3.0**: that slot was repurposed as the PPU-accuracy capstone and has shipped. Any store listing is a later, unversioned step — see `to-dos/ROADMAP.md`. --- @@ -186,7 +186,7 @@ These cross-cutting decisions span multiple files. Reading individual chip docs - Branch names: `/`. - A chip-behavior change touches both the chip code and the chip's `docs/.md`. They drift apart easily; don't let them. - For accuracy work: pin the failing test ROM expectation first, then implement until it passes. -- Hot paths (`Cpu::tick`, `Ppu::tick`, mapper register access): no allocations, prefer fixed arrays, profile (`cargo bench` + `perf record`) before adding abstractions. Target ≤ 2 ms/frame headless. +- Hot paths (`Cpu::tick`, `Ppu::tick`, mapper register access): no allocations, prefer fixed arrays, profile (`cargo bench` + `perf record`) before adding abstractions. **On the frame-cost number:** the `≤ 2 ms/frame headless` figure in `docs/performance.md` is a **design-phase aspiration** (written before the cycle-accurate core existed, for 2018-era Skylake) — it is NOT a live gate. The implemented core measures **~3.9 ms** (`nes_run_frame_nestest_fast`) / **~2.5 ms** (`flowing_palette`), which `docs/performance.md` records as knowingly accepted for the master-clock design. That is ~23% of the 16.639 ms NTSC budget. The dominant costs are work the accuracy model *requires* — `cpu_clock` is APU BLEP synthesis + the non-linear mixer (mixer ceiling measured ≤1.9%), and `Ppu::tick` is the per-dot lockstep loop — and the obvious levers were already measured and **rejected** (`emit_pixel` bounds-check elision was *slower*; the SIMD blitter was *slower*). Do not "optimize toward 2 ms" by trading away accuracy; the real-world multiplier on frame cost is **run-ahead**, not the per-frame core cost. Any optimization must clear the project's **>3% same-runner A/B bar and stay byte-identical**. - `unsafe` requires a `// SAFETY:` comment explaining the invariant. The chip stack is `#![no_std]` + `extern crate alloc;`; only `rustynes-frontend` and `rustynes-cheevos` (FFI) carry `unsafe`. - **Comprehensive rustdoc + comments (project rule).** Craft extensive `//!` crate/module preambles and `///` / `//` inline comments matching the quantity, quality, and technical depth of the existing `rustynes-*` crates — explain the *why* alongside the architectural detail, the memory-safety guarantees, and the lockstep-timing considerations. - **Comprehensive commit bodies (project rule).** Commit message bodies are robust, comprehensive, and technically detailed: go beyond a summary to explain architectural impact, the mathematical implementation, memory constraints, and the deep technical specifics (the maintainer's house style; see `docs/guidelines`). @@ -198,7 +198,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.9 "Studio II"** (2026-08-04, a frontend quality-of-life release — TAStudio piano-roll edits wired to the emulator, `.bk2` playback honoring the movie's `LogKey` column order, and a detach/pop-out affordance for tool windows (the shared `detachable_window` helper across 18 panels) [native-only; **currently embeds** on the single-viewport `egui_winit` integration rather than opening a separate OS window, so the Windows-10 trapped-window fix awaits multi-viewport render-loop wiring — tracked follow-up]; frontend-only so the deterministic core is untouched and AccuracyCoin holds 141/141, nestest 0-diff), on top of **v2.2.8 "Aperture II"** (2026-08-04, a presentation-fidelity release — gamma-correct scanlines + a WebGL2 gamma fix + a sharper scanline profile; presentation-only so the pre-shader framebuffer + AccuracyCoin 141/141 are byte-identical, native default unchanged; visual verification pending), on top of **v2.2.7 "Timbre II"** (2026-08-04, an expansion-audio fidelity release — VRC6 recalibrated to ~1.0× a 2A03 pulse per the NESdev/field consensus [`VRC6_MIX_SCALE` 979→650; Mesen2's ~1.5× was the loud outlier], and the Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC; expansion-only, so the base 2A03 is byte-identical and AccuracyCoin holds 141/141), on top of **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.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.9 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio [v2.2.7, shipped], video/gamma [v2.2.8, shipped], TAS/UX [v2.2.9, shipped], 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.3.0 "Datum II"** (2026-08-05, the capstone closing the v2.2.6 → v2.3.0 NESdev-remediation line — **true multi-viewport OS-window detach** for every tool panel (v2.2.9's affordance only *embedded* them, so the Windows-10 trapped-window report is now genuinely fixed); a **frame-pacing fix** predating that work (the render path held the emulator lock across the blocking swapchain acquire + present, stalling frame production whenever a debugger panel was open — now split so the lock covers only the egui UI build, plus `pace_frames` reading a lock-free `has_rom` atomic instead of locking every `about_to_wait`); a **−5.13% / −3.51%** byte-identical PPU optimization (`v2.3.0 P1`: `#[inline]` on the per-dot sprite eval + hoisting the `tick_oam_bus` early-out); both remaining forum-reported accuracy items (SMB left edge, Rad Racer hybrid-address) **verified already-correct**; and the AccuracyCoin gate pinned to an **exact 141/141**), on top of **v2.2.9 "Studio II"** (2026-08-04, a frontend quality-of-life release — TAStudio piano-roll edits wired to the emulator, `.bk2` playback honoring the movie's `LogKey` column order, and a detach/pop-out affordance for tool windows (the shared `detachable_window` helper across 18 panels) [native-only; it **embedded** the panel on the single-viewport `egui_winit` integration rather than opening a separate OS window — **resolved in v2.3.0** by the real multi-viewport implementation]; frontend-only so the deterministic core is untouched and AccuracyCoin holds 141/141, nestest 0-diff), on top of **v2.2.8 "Aperture II"** (2026-08-04, a presentation-fidelity release — gamma-correct scanlines + a WebGL2 gamma fix + a sharper scanline profile; presentation-only so the pre-shader framebuffer + AccuracyCoin 141/141 are byte-identical, native default unchanged; visual verification pending), on top of **v2.2.7 "Timbre II"** (2026-08-04, an expansion-audio fidelity release — VRC6 recalibrated to ~1.0× a 2A03 pulse per the NESdev/field consensus [`VRC6_MIX_SCALE` 979→650; Mesen2's ~1.5× was the loud outlier], and the Sunsoft 5B envelope moved to the exact 5-bit 1.5 dB/step DAC; expansion-only, so the base 2A03 is byte-identical and AccuracyCoin holds 141/141), on top of **v2.2.6 "Almanac"** (2026-08-04, a de-monetization + provenance release — RustyNES is permanently open-source and income-free per ADR 0035; all planned monetization removed, native apps kept as free FOSS apps, and the TriCNES hybrid-address timing-calibration caveat disclosed per ADR 0030 for a v2.3.0 rework; zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction), on top of **v2.2.5 "Colophon"** (2026-08-03, a provenance/licensing/documentation-integrity release — zero emulation-core behavior changes so AccuracyCoin holds 141/141 by construction; `NOTICE` rewritten for full attribution + GPL-oracle disclosure + GeraNES, in-source "port" comments reworded to the oracle framing, the CRT-shader/NTSC provenance reworded to independent reimplementations, `docs/originality-and-provenance.md` added, README AI-assistance disclosure), on top of **v2.2.4 "Cartridge"** (2026-07-24, a libretro/RetroArch distribution cut — zero emulation-core changes so AccuracyCoin holds 141/141 by construction; the libretro core is confirmed up-to-date with all recent changes and builds for the buildbot ABIs [`x86_64-pc-windows-gnu`, `aarch64-linux-android`], and `rustynes_libretro.info` is corrected: `disk_control` false→true [the FDS Disk Control interface was wired but advertised absent], `display_version` v1.0.0→v2.2.4, mapper count 168→172; core options remain a documented future enhancement; the Antigravity reviewer standardization rides along), on top of **v2.2.3 "Datum"** (2026-07-23, a performance and accuracy-closure patch — the fast PPU dot path promoted to default and exposed, PGO binaries shipped on the release path, a same-runner relative frame-time CI gate, the last two Holy Mapperel residuals closed [MMC1 WRAM write-protect + FME-7 open bus, all 17 ROMs now `detail=0000`], the Sunsoft 5B level calibrated with `Mapper::mix_audio` widened to i32, a save-state schema gap fixed at `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail, an opt-in Zapper beam-relative light model, and the eleven `sprintN.rs` mapper modules renamed to `mNNN_.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.3.0 is released** — the **v2.2.6 → v2.3.0** line (de-monetization + NESdev remediation: audio [v2.2.7, shipped], video/gamma [v2.2.8, shipped], TAS/UX [v2.2.9, shipped], and the PPU left-edge + hybrid-address accuracy capstone at **v2.3.0** "Datum II" [shipped]) is now **complete**. 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 reference-emulator clone, now removed from disk but kept in the ignore lists as a firewall guard so it can never re-enter the tree — see the MOST IMPORTANT RULE section above), the vendored `tricnes/` + upstream READMEs, and the frozen `docs/archive/` + `to-dos/archive/` trees — don't lint or reformat those. diff --git a/CHANGELOG-FULL.md b/CHANGELOG-FULL.md index 25662389..a01626bd 100644 --- a/CHANGELOG-FULL.md +++ b/CHANGELOG-FULL.md @@ -217,7 +217,8 @@ the future v2.1.0 joint launch); Android continues as GitHub-sideload. "Option 2 = proven-correct Mesen2 recipe" premise was false; the correct oracle is **TriCNES** (`ref-proj/TriCNES/Emulator.cs`, MIT, commit `9199870` — the AccuracyCoin author's own emulator), which models the multiplexed AD/A bus + octal - latch at transistor level and does drive `$2F19` / `$0FFF`; and (2) **a whole-dot + latch at the pin/sub-cycle level and does drive `$2F19` / `$0FFF` (corrected in + v2.3.0: TriCNES is a cycle-accurate C# emulator, not transistor-level); and (2) **a whole-dot port of TriCNES's octal latch suffices** — the full 2-cycle-ALE fetch refactor (v2.0.1's Option 1) was not required. The maintainer's **refine-then-promote** decision is recorded: ship flag-off in v2.0.2 (shipped stays honest 139/141), diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c244c3..1edca775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,123 +14,162 @@ cycle-accurate core later replaced. ## [Unreleased] -## [2.2.9] - 2026-08-04 - "Studio II" (relicense to GPLv3 + TAS/movie wiring + detachable tool windows) +## [2.3.0] - 2026-08-05 - "Datum II" (PPU-accuracy capstone + true multi-viewport tool windows) -The fourth step of the **v2.2.6 → v2.3.0** NESdev-remediation line. Its headline -is a **licensing and provenance correction**: RustyNES is **relicensed to -GPL-3.0-or-later** because it is a derivative work of GPL emulators. It also -addresses three forum items — TAStudio piano-roll edits that never reached the -emulator, `.bk2` movies that imported but did not play back, and tool windows -trapped inside the main OS window on Windows 10. The code changes are -frontend-only, so the deterministic chip stack, save-states, and every golden -vector are byte-identical (AccuracyCoin 141/141, nestest 0-diff). - -> **Windowing — honest scope.** The detach affordance is **native-only and -> currently *embeds***: the frontend is a single-viewport `egui_winit` -> integration, so `show_viewport_immediate` renders a detached panel *inside* the -> main window rather than as a separate OS window — so this does **not** yet fully -> resolve the Windows-10 "trapped window" report. True OS-window detach needs -> multi-viewport render-loop wiring (`set_embed_viewports(false)` + per-viewport -> winit windows), tracked as a v2.3.0 follow-up (see the detailed "Fixed" entry). +Closes the **v2.2.6 → v2.3.0 NESdev-remediation line**. Both remaining +forum-reported accuracy concerns were investigated and found *already correct*; +the release's substance turned out to be elsewhere — real OS-window tool panels, +and a frame-pacing defect that had been degrading every session with a debugger +panel open. -### Changed — License: MIT/Apache-2.0 → GPL-3.0-or-later +### Added -- **RustyNES is relicensed to GPL-3.0-or-later** (ADR 0036). A NESdev community - review established that the project **incorporates and is derived from code from - GPL-licensed emulators** — principally **Mesen2** (GPL-3.0-or-later: CPU unstable - stores, the PPU sprite-evaluation/OAM model, ~15 mapper boards, the Bisqwit NTSC - filter tables, EEPROM models, the UNIF tables, the debug-symbol importer, the PGO - harness) and, for several mappers and the FDS drive model, **puNES / FCEUX / - Nestopia** (GPL-2.0-or-later: JV001/mapper-147 bit-for-bit, the FDS per-CRC drive - table, UNIF handling). This is derivation, not oracle use — the project's own - pre-v2.2.5 comments said so ("Faithful port of Mesen2's …", "Ported bit-for-bit - from puNES `JV001.c`") — which makes RustyNES a derivative work distributable only - under the GPL. -- **The v2.2.5 "no GPL source incorporated" / MIT-Apache position was wrong and is - withdrawn.** `LICENSE` is now the GPLv3 text; `LICENSE-MIT` / `LICENSE-APACHE` are - removed; the workspace + `rustynes-cheevos` `license` fields and the `cargo-deny` - allow-list are updated. -- **Credit is given, per subsystem.** `docs/originality-and-provenance.md` is - rewritten to lead with the file-by-file derivation table and the derivative-work - declaration; `NOTICE` attributes every GPL upstream and the code derived from it. - Each derived source file now carries an accurate `SPDX-License-Identifier: - GPL-3.0-or-later` header plus a specific provenance note naming its upstream - (e.g. Mesen2 `NesPpu.cpp`, puNES `JV001.c`) and pointing to the audited record. - The old scattered, imprecise per-line "port of" comments are not restored — the - SPDX + provenance headers are their accurate replacement. Incorporated permissive - components (emu2413/MIT, TriCNES/MIT, rcheevos/MIT, blip_buf/LGPL-2.1-or-later, - fonts) are GPL-compatible and keep their notices. Zero emulation-core behavior - change. +- **True multi-viewport tool-window detach.** Every tool panel can now pop out + into a *real OS window* (native), finally resolving the Windows-10 + "trapped window" report. v2.2.9's affordance only *embedded* the panel, because + `show_viewport_immediate` needs a multi-viewport integration to produce a real + window. The new `rustynes-frontend/src/detached.rs` gives each detached panel + its own winit window + egui context/state/renderer + wgpu surface, sharing the + main device — **with no `unsafe`**, unlike eframe's immediate-viewport path + (which erases an `ActiveEventLoop` lifetime into a `'static` thread-local). + The nine panels that predated the shared helper (CPU, Cartridge Info, Lua + Script, BasicBot, Input bindings, TAStudio, Settings, Netplay, + RetroAchievements) were converted, so *all* tool windows are detachable. + Detached windows inherit the main window's theme, zoom, and locale, and open at + the size their docked window actually had. wasm keeps panels docked. -### Added — Provenance & license firewall (+ import hardening) +### Fixed + +- **Frame stutter / high produced-interval p99 whenever a debugger or tool panel + was open.** The overlay-visible render path held the emulator mutex "until + after the present call" — and the *blocking* `Surface::get_current_texture` + runs before the egui pass — so the winit thread owned the lock across a + swapchain wait, the whole egui build, the encode and the present, while the + emulation thread sat parked on `emu.lock()` unable to produce a frame. + `render_shell` is split into `run_shell_ui` (locked, needs `&mut Nes`) and + `paint_shell` (unlocked GPU work); the guard now covers only the UI build. +- **`pace_frames` took the emulator mutex on every `about_to_wait` iteration** — + a tight spin in the wall-clock regime — which could block the UI thread for a + full produce (~4 ms) each time. It now reads the lock-free `EmuControl::has_rom` + atomic, falling back to the locked read only when no emulation thread exists. + +### Changed + +- **PPU per-dot helpers optimized: −5.13% / −3.51% frame cost** (nestest / + flowing-palette, both clearing the project's >3% adoption bar, p = 0.00). + `perf annotate` showed `tick_sprite_eval_per_dot`'s own `push`/`ret` were the + two hottest instructions in its body — pure call overhead across 89,342 + calls/frame — and that `tick_oam_bus` derived values it discarded before its + dot-0 early-out. Byte-identical: AccuracyCoin **141/141**, nestest 0-diff, PPU + units 91/91. Documented as `v2.3.0 P1` in `docs/performance.md`. +- **Detached panels repaint on per-panel tiers** — Live (60 Hz) for + continuously-changing state, Throttled (~10 Hz) for status, and + interaction-only for static panels (Cheats, ROM Info, Settings) — so a wall of + open tool windows costs almost nothing while idle. +- **AccuracyCoin gate pinned to an exact 141/141.** The gate asserted only a + coarse 60% floor — too loose to catch a single-test regression (an A/B probe + disabling the delayed-`CopyV` dropped exactly the Hybrid Addresses test to + 140/141 yet still cleared 60%). It now also asserts zero failing tests. +- **The `≤ 2 ms` frame-cost figure is now labeled a design-phase aspiration, not + a gate.** It was written before the cycle-accurate core existed; the core + measures ~3.8 ms (~23% of the NTSC budget) and that is knowingly accepted. The + remaining bulk is accuracy-required work, and the obvious levers were already + measured and *rejected* (`emit_pixel` elision and the SIMD blitter were both + **slower**). Recorded so no contributor optimizes toward it by trading accuracy. +- **libretro core license declared as `GPLv3`**, matching the notation mesen / + melonDS / bsnes use (SPDX `GPL-3.0-or-later` stays in the Cargo metadata). + +### Verified (no change required) + +- **SMB left-edge and the hybrid-address (Rad Racer) render.** Both were + investigated under reproduce-before-fixing discipline and found *already + correct* in the shipped build — resolved by the v2.0.0 "Timebase" rewrite and + the v2.0.3 2-cycle-ALE promotion, predating the report. SMB's leftmost + background column renders real content; the hybrid-address model passes the + authoritative AccuracyCoin test and renders Rad Racer cleanly. See ADR 0030's + v2.3.0 update. + +### Documentation + +- **Hybrid-address provenance finalized** — `NOTICE`, `docs/originality-and-provenance.md` + §4 and ADR 0030 move from "TriCNES-calibrated, being reworked" to "verified + correct, documentation/oracle-derived". +- **TriCNES is no longer described as "transistor-level"** — it is a + cycle-accurate C# emulator with a sub-cycle state machine. The term properly + denotes die-derived simulations (`Visual2C02` / `phantom2c02`), which the repo + cites correctly. Corrected in source, `NOTICE`, README, ADR 0030, and the + published v2.0.2 / v2.2.5 release notes. +- **GeraNES reference comments corrected** — dangling source-file paths and a + quoted C++ line were reworded to state honestly that its source was consulted + as a cross-reference for nesdev-documented behavior, with no code copied + (reviewed two-sided against the upstream source and the nesdev register maps). + Recorded as an assessment for expert review, not a self-certification. + +## [2.2.9] - 2026-08-04 - "Studio II" (relicense to GPLv3 + TAS/movie wiring + detachable tool windows) + +The fourth step of the **v2.2.6 → v2.3.0** NESdev-remediation line. Its headline is +a **licensing and provenance correction** — RustyNES is **relicensed to +GPL-3.0-or-later** as the derivative work of GPL emulators it is — alongside three +forum-reported fixes (TAStudio edits, `.bk2` playback, tool-window detachment). The +code changes are frontend-only, so the deterministic chip stack, save-states, and +every golden vector are byte-identical: **AccuracyCoin 141/141, nestest 0-diff**. -- **Guardrails ruleset + post-mortem.** `docs/ai-emulator-provenance-guardrails.md` - — a preventive, console-agnostic ruleset (reference firewall, four attribution - surfaces, license accounting, mechanical CI enforcement, a pre-development - checklist, a paste-ready block, red flags) that stops the copyleft-source-lifting - failure from recurring in any AI-assisted emulator project (shared as community - best-guidance) — and `docs/provenance-failure-postmortem.md`, the forensic - root-cause analysis of how GPL code was reproduced despite a black-box - instruction and then laundered, and how it was corrected. Themed PDFs of both in - `ref-docs/`. The guardrails are **ingested into `AGENTS.md`** (via the - `CLAUDE.md` / `GEMINI.md` symlinks) as the **"MOST IMPORTANT RULE"** section and - into the project memory bank, so every session loads the reference firewall as - standing context. A "Provenance & Licensing" section links them from - `docs/DOCUMENTATION_INDEX.md` + the mkdocs nav, and `README.md` carries a - reference-firewall note. -- **Reference firewall — the reference-emulator clone removed.** The local - reference-emulator clone has been **removed from disk**; it stays gitignored (and - excluded from `.dockerignore` / `.markdownlintignore` / pre-commit / CodeRabbit) - as a firewall guard so the *copyleft* references' source (Mesen2 / puNES / FCEUX / - GeraNES, GPL) is out of the agent's reach by design. In-source provenance - citations were normalized from the removed local-clone path to upstream-relative - form (comments-only — the deterministic core is byte-identical; nothing - laundered), and the `docs/originality-and-provenance.md` §1 derivation table was - audited so every upstream header a file's comments cite is listed. **MIT TriCNES - is the deliberate exception**, vendored in-repo with attribution under - `crates/rustynes-test-harness/golden/tricnes/`; the tooling docs - (`oracle-tooling-setup.md`, `ppu-trace-tooling.md`) scope the out-of-tree / - never-reproduce rule to the copyleft references accordingly. -- **`.bk2` import hardened against a `LogKey` allocation-amplification DoS.** - `bk2_interop::parse_log_key` now reads only the console/P1/P2 groups from the - `split('#')` iterator instead of collecting every `#`-group, so a hostile movie - padded with `#` delimiters can no longer amplify into an unbounded `Vec<&str>` - on import. Behavior is identical for valid movies (guarded by a new - `log_key_bounded_against_pathological_group_padding` regression test); the - deterministic core is unaffected. +> **Windowing — honest scope.** The detach affordance is **native-only and currently +> *embeds***: the frontend is a single-viewport `egui_winit` integration, so +> `show_viewport_immediate` renders a detached panel *inside* the main window, not as +> a separate OS window — so this does **not** yet fully resolve the Windows-10 +> "trapped window" report. True OS-window detach needs multi-viewport render-loop +> wiring, tracked as a v2.3.0 follow-up. + +### Changed — License: MIT/Apache-2.0 → GPL-3.0-or-later + +- **RustyNES is relicensed to GPL-3.0-or-later** (ADR 0036). A NESdev community review + established that it **incorporates code derived from GPL emulators** — principally + **Mesen2** (GPL-3.0-or-later: CPU unstable stores, PPU sprite-eval/OAM, ~15 mapper + boards, Bisqwit NTSC tables, EEPROM/UNIF/debug-symbol/PGO code) and, for several + mappers and the FDS drive model, **puNES / FCEUX / Nestopia** (GPL-2.0-or-later). + The pre-v2.2.5 comments said as much ("Faithful port of Mesen2's …"); the v2.2.5 + "no GPL source incorporated" / MIT-Apache position was wrong and is withdrawn. +- **Credit is given, per subsystem.** `docs/originality-and-provenance.md` leads with + the file-by-file derivation table; `NOTICE` attributes every GPL upstream; each + derived file carries an accurate `SPDX-License-Identifier` + provenance header (the + old imprecise "port of" comments are not restored — the headers are their accurate + replacement). Incorporated permissive components (emu2413/MIT, TriCNES/MIT, + rcheevos/MIT, blip_buf/LGPL-2.1-or-later, fonts) keep their notices. Zero + emulation-core behavior change. ### Fixed -- **TAStudio piano-roll edits now drive the emulator.** `App::handle_tas_requests` - applied `TasRequest::SetInput` to the `TasEditor::input_log` only and never - re-seeked the `Nes`, so a cell edit was invisible until an unrelated seek. It - now marks the buffer dirty and re-derives through `TasEditor::seek` after the - batch — the same path the scripting bridge (`apply_tas_commands`) already used. -- **`.bk2` playback honors the movie's `LogKey` column order.** The importer - mapped controller columns by a fixed built-in order and ignored the `LogKey:` - header, so real BizHawk movies whose columns are ordered differently drove the - wrong buttons. `bk2_interop` now parses the actual `LogKey:` order (falling back - to the standard order when absent), and import parse errors surface on the - on-screen status bar instead of only `eprintln!`. +- **TAStudio piano-roll edits now drive the emulator.** `handle_tas_requests` applied + `SetInput` to the editor's `input_log` only and never re-seeked the `Nes`; it now + re-derives through `TasEditor::seek` after the batch, matching the scripting bridge. +- **`.bk2` playback honors the movie's `LogKey` column order.** The importer mapped + columns by a fixed order and ignored the `LogKey:` header, driving the wrong buttons + on movies ordered differently; it now parses the real order (falling back to the + standard order when absent) and surfaces parse errors on the status bar. -### Added +### Added — Provenance & license firewall (+ import hardening) -- **Detachable / floating tool windows (native).** A shared `detachable_window` - helper gives each debugger/tool panel a "⧉ Detach" button (and a "⧉ Reattach" - affordance) that pops it out via egui's `show_viewport_immediate`; 18 panels are - routed through it (PPU, OAM, APU, Memory, Event Viewer, NSF, Mapper, Watch, - Trace, Cheats, ROM Database, Performance, Documentation, Input Display, Audio - Mixer, Replay/TAS, Memory Compare, ROM Info), each preserving its prior - first-open geometry via a `WindowCfg`. Native-only (wasm stays docked in an - `egui::Window`, unchanged), clippy-clean on both wasm feature sets. - - **Known limitation (honest scope).** RustyNES's frontend is currently a - *single-viewport* `egui_winit` integration, so `show_viewport_immediate` - renders the detached panel **embedded in the main window** rather than as a - separate OS window — i.e. this does **not** yet fully resolve the Windows-10 - "trapped inside the main window" report. True OS-window detach requires wiring - multi-viewport (`set_embed_viewports(false)` + per-viewport winit windows) into - the render loop; the affordance, `WindowCfg` geometry, and `ViewportBuilder` - plumbing are in place for when that lands. Tracked as follow-up. +- **Guardrails ruleset + post-mortem.** `docs/ai-emulator-provenance-guardrails.md` (a + preventive, console-agnostic reference-firewall / attribution / license ruleset, + shared as community best-guidance) and `docs/provenance-failure-postmortem.md` (the + forensic root-cause of how GPL code was reproduced despite a black-box instruction, + then laundered, and how it was corrected). Themed PDFs of both in `ref-docs/`. + Ingested into `AGENTS.md` as the top development rule. +- **Reference firewall — the reference-emulator clone removed.** The local clone is + deleted from disk and stays gitignored (+ excluded from dockerignore / + markdownlintignore / pre-commit / CodeRabbit) so the *copyleft* references' source + is out of reach; in-source citations were normalized to upstream form (comments-only, + byte-identical), and the §1 derivation table audited for completeness. MIT TriCNES + is the deliberate exception, vendored in-repo with attribution. +- **`.bk2` import hardened against a `LogKey` allocation-amplification DoS** — + `parse_log_key` reads only the console/P1/P2 groups from the `split('#')` iterator + instead of collecting every `#`-group; behavior is identical for valid movies (new + `log_key_bounded_against_pathological_group_padding` regression test). +- **Detachable / floating tool windows (native).** A shared `detachable_window` helper + gives 18 debugger/tool panels a "⧉ Detach" button via `show_viewport_immediate`, + each preserving its geometry (wasm stays docked). Currently embeds rather than + opening a separate OS window — see the honest-scope note above. ## [2.2.8] - 2026-08-04 - "Aperture II" (gamma-aware scanlines + sharper CRT) @@ -378,638 +417,122 @@ byte-identical to v2.2.3 by construction. ## [2.2.3] - 2026-07-23 - "Datum" (fast dot path promoted + PGO shipped + the last two mapper residuals closed) +A performance and accuracy-closure patch. No *regression* on the deterministic core — +**AccuracyCoin 141/141, nestest 0-diff**, `visual_regression` and the APU oracles unmoved. +(This release does change shipped-default behavior by design: the fast PPU dot path became +the default, two Holy Mapperel mapper residuals were closed, the Sunsoft 5B level was +calibrated, and the save-state schema gained `PPU_SNAPSHOT_VERSION` 8 + an APU v4 tail — +each an intentional, oracle-gated change, detailed below.) + ### Performance -- **The specialized PPU fast dot path is now the default (`~11%` faster on - rendering-heavy content).** `Ppu::tick` is the emulator's hottest function - (32.8% of frame self-time in a fresh profile of the 7-ROM training corpus); - v2.1.8 added `tick_visible_render_fast`, a straight-line handler for the - common undisturbed visible background dot, and shipped it **off** as that - roadmap's highest-risk item, pending "maintainer review and a clean-host - Criterion confirmation". Both conditions are now met, so it defaults on. - - Clean-host `full_frame`: `nes_run_frame_nestest` **4.4343 ms → 3.9331 ms - (−11.3%)**; the rendering-disabled `flowing_palette` workload is unchanged - (−0.07%, noise — its guard bails at `rendering_enabled()`). This reproduces - v2.1.8's interleaved +12.3% measurement by a different method. - - **Byte-identical, and not newly so:** `fast_dotloop_diff.rs` has compared both - paths' framebuffer + palette-index framebuffer + audio + CPU-cycle count + - full core snapshot *every frame* since v2.1.8. Re-verified with the new - default across the whole `--features test-roms` suite. *At the promotion - commit* that read **2219 passed / 0 failed** — the pre-promotion 2218 plus - exactly the one config test added alongside, with no test changing its - verdict, which is the number that matters for this item. The suite total then - grew with the later work in this release; the figure for the release as a - whole is **2238 passed / 0 failed / 20 ignored** (see the Verification block - and `.github/release-notes/v2.2.3.md`, which quote that same total). - AccuracyCoin 141/141, nestest 0-diff, `visual_regression` and the APU oracles - unmoved. - - Until now the win was **unreachable in practice**: `Nes::set_fast_dotloop` had - no callers outside the core and its tests, so no shipped frontend - configuration could turn it on. +- **The specialized PPU fast dot path is now the default** (~−11% frame time on + rendering-heavy content; `nes_run_frame_nestest` 4.43 ms → 3.93 ms). Differential-tested + byte-identical every frame since v2.1.8 (`fast_dotloop_diff.rs`); it had shipped off + with no reachable caller. A `[emulation] fast_dotloop` escape hatch defaults on. +- **Release builds ship the PGO binary on `x86_64-unknown-linux-gnu`.** `release.yml` + now consumes the profile-guided build behind the existing >3%-faster-and-byte-identical + gate; a gate miss silently keeps the plain asset (macOS/Windows unchanged). +- **A same-runner relative frame-time regression gate** (`bench_relative_check.sh`) now + fails a >10% back-to-back slowdown, closing the hole where the loose absolute ceiling + let a 2.5× slowdown pass. ### Fixed -- **The last two Holy Mapperel mapper residuals are closed — all 17 ROMs now - report `detail=0000`** (was 15/17). Both were single missing register states, - not bank-reachability defects. - - **MMC1 software WRAM write-protect.** MMC1 has *two* PRG-RAM write-protect - layers and RustyNES modelled neither, reading and writing `$6000-$7FFF` - unconditionally: the `$E000` bit-4 disable common to every board, and SNROM's - second layer, where on a CHR-**RAM** board the CHR bank register's bit 4 is - wired to the RAM's other enable. That is exactly what Holy Mapperel measured — - `1000` on SJROM (one layer) versus `5000` on SNROM (both). The SNROM layer is - gated on `chr_is_ram`, since on a CHR-ROM board those bits are real CHR - banking; getting that wrong would break every SJROM/SUROM title, so it carries - its own negative-control test. - - Holy Mapperel's README calls this a game-compatibility hazard and notes FCEUX - and PowerPak decline to model it, so it was validated before landing rather - than assumed: the commercial-ROM oracle passes **60/60** — including seven - battery-backed MMC1 saves (Zelda, Metroid, Final Fantasy, Mega Man 2, - Castlevania II, Ninja Gaiden, Kid Icarus), precisely the titles that corrupt if - the RAM enable is wrong — and the extended corpus **138/138**. No regression, - so it ships on by default. - - **FME-7 open bus on the RAM-selected-but-disabled window.** Command `$8` - bit 6 = 1 with bit 7 = 0 drives neither the RAM nor the ROM chip, so the - databus floats; RustyNES fell through to the PRG-ROM bank and returned its tag - byte. Both fixes route through `Mapper::cpu_read_unmapped`, the trait's - existing "not wired to mapper-resident memory" contract, so the bus preserves - the open-bus latch instead of clobbering it. - - Each was negative-controlled by decoding the ROM's on-screen result to ASCII - and confirming that reverting flips the digit back — the harness's `detail=` - string is a hand-maintained classification, not a measurement. - -- **Seven commercial-oracle audio rows had gone silently stale; re-blessed with - their provenance recorded.** They failed on `audio_fnv1a64` alone — frames, - cycles, sample counts and every framebuffer checkpoint byte-identical. The - MMC5 (×3) and VRC6 (×3) rows had been stale since **v2.1.6**, when - `VRC6_MIX_SCALE` and all three MMC5 level constants were recalibrated: those - snapshots were last blessed 2026-06-13, 28 days earlier. The FME-7 row moved - with this line's own 5B level calibration. - - Root cause was structural — the suite needs `--features commercial-roms` **and** - local gitignored ROM dumps, so neither CI nor the default gate can run it, and - a golden vector nothing executes only accumulates drift. A new - `expansion_level_tripwire` unit test (which CI *does* run) pins every - expansion-audio level constant and fails with instructions naming both suites - that must be re-blessed in the same change. - -- Sunsoft 5B audio register file (`$07` mixer, `$08-$0A` volumes, envelope - period/shape/output, live mix value) is now surfaced in the FME-7 mapper - debug window (`Nes::mapper_info()`). Added while diagnosing the snapshot-window - gap below — the 5B was the only part of that board with no debug view, and its - mixer/volume bytes are exactly what answer "why is this cart silent?". - -- **Sunsoft 5B expansion audio was ~23 dB too quiet; now calibrated.** The 5B's - logarithmic DAC *shape* has been hardware-exact since v2.1.6, but its - *absolute level* was a documented gap — for one reason only: `Mapper::mix_audio` - returned `i16`, and the correct full-scale tone is `1882 × 18.471 = 34,761`, - past `i16::MAX` for a **single** channel (three simultaneous tones, as in - Gimmick! / Hebereke, reach ~104 k — 3.2× over). The blocker was the type, not - the value. - - The trait return is widened to **`i32`** and the level calibrated by - `SUNSOFT5B_MIX_SCALE_NUM/DEN = 2549/138 ≈ 18.471`. `db_5b` measured - **0.0685×** before and **1.2651×** after, against a target derived from Mesen2 - rather than from our own prior numbers: `LUT[12]=63 × mixer weight 15 / 746.9 - = 1.265` (full scale `3.554`), independently reproducing the figures the - accuracy ledger recorded when the work was deferred. Now asserted by a new - `level_db_5b` oracle, so shape and level are each pinned by their own test. - - Every other board returns exactly the values it always did — the widening is - representational. AccuracyCoin 141/141, the other 24 audio-expansion tests, - and the APU oracles are unchanged. - - One consequence had to be chased down rather than left: `NsfExpansion::mix` - summed the chips into an `i16` **with a clamp**, which was harmless while - every chip fitted — but a calibrated 5B reaches ~104 k at full scale, so an - NSF 5B tune would have *clipped* where the identical cartridge 5B path does - not. Since the entire point of `nsf_expansion` is that an NSF tune sounds - bit-for-bit like the cartridge, that function is now `i32` and unclamped. - -- **The expansion-audio snapshot layer was blind to expansion audio.** Snapshots - captured 120 frames, but these ROMs hold a 2A03 reference tone first and do - not switch the expansion chip in until ~frame 560 — so the "load-bearing audio - sentinel" hashed boot and the reference section and never observed the chip - under test. Found by accident: the 5B level change above is **18.5×** and all - six 5B snapshots stayed byte-identical. - - The capture window now spans the expansion segment (660 frames, reusing - `DB_FRAMES`), and all 19 snapshots are re-blessed accordingly — the hash - changes are the window extension plus, for the 5B ROMs, the level fix. - Verified by perturbation rather than assumed: a **one-unit** scale change - (0.04%) now fails **all six** 5B snapshots, where an 18.5× change previously - failed none. - - Reaching all six needed per-ROM windows, not just a bigger shared one. - Instrumenting the 5B register file (new `5b_*` rows in the FME-7 debug - window, `Nes::mapper_info()`) measured each ROM's first non-zero 5B output: - `db_5b` ~540, `envelope_5b` ~420, but **`noise_5b` ~900** and **`sweep_5b` - ~4740** (~79 s). Neither late ROM is broken and neither awaits input — - `noise_5b` enables noise on channel A about 15 s in, and `sweep_5b` runs a - slow volume sweep holding mixer `$3F` (the "both bits set ⇒ constant output - at volume" case). They simply outlast the shared window, so they now get - `NOISE_5B_FRAMES` / `SWEEP_5B_FRAMES` of their own. - -- **Run-ahead cost three AccuracyCoin tests.** The PPU save-state carried - `secondary_oam` but not the sprite-evaluation FSM that fills it — the - `sprite_eval_*` pointers and phase flags, the parallel OAM-data-bus model - (`oam_bus_*`), and the clear-window write pointer `oam2_addr`. The frontend's - run-ahead (`[input] run_ahead`, **default 1**) snapshots and restores the core - once per visible frame, so every frame restored a full secondary-OAM buffer - next to a reset walker. The battery measured 141/141 headless but **138/141** - through the desktop app, failing `Sprite Evaluation :: Arbitrary Sprite zero` - (error 2), `Sprite Evaluation :: Misaligned OAM behavior` (error 1), and - `PPU Behavior :: Rendering Flag Behavior` (error 2). Serializing that state in - a new `PPU_SNAPSHOT_VERSION` **v8** tail (50 bytes) restores **141/141 with - run-ahead on**, at depth 1 and 2. Same bug class as the v6 tail (Wizards & - Warriors), a different uncovered field set; Mesen2 serializes the equivalent - fields. Netplay rollback and TAS seeking take the same round trip and get the - same fix. New regression net: - `crates/rustynes-test-harness/tests/accuracycoin_runahead.rs` reruns the whole - battery through the run-ahead cycle and names any test it costs. +- **The last two Holy Mapperel residuals are closed — all 17 ROMs report `detail=0000`**: + MMC1's two software WRAM write-protect layers (`$E000` bit 4 + SNROM's `chr_is_ram` + CHR-register layer) and FME-7's open bus on the RAM-selected-but-disabled window, both + via the trait's `cpu_read_unmapped` contract. Validated 60/60 commercial (incl. seven + battery-backed MMC1 saves) + 138/138 extended. +- **Sunsoft 5B expansion audio calibrated (~23 dB louder).** The DAC shape was already + exact; the level was blocked by `Mapper::mix_audio` returning `i16` (full-scale 5B = + 34,761). Widened to **`i32`** and calibrated against Mesen2 (`db_5b` 0.069× → 1.265×); + `nsf_expansion::mix` likewise widened + unclamped. Every other board is byte-identical. +- **Run-ahead cost three AccuracyCoin tests** (138/141 in-app vs 141 headless): the PPU + snapshot omitted the sprite-evaluation FSM + OAM-data-bus state. A new + `PPU_SNAPSHOT_VERSION` **v8** tail restores **141/141 with run-ahead on**; an APU **v4** + tail closes a matching warm-reset `$4017` gap. Netplay/TAS take the same round-trip. +- Seven stale commercial-oracle audio rows re-blessed (level constants changed in + v2.1.6 / this line; a new `expansion_level_tripwire` CI test pins them), and the + expansion-audio snapshot window widened so it actually observes the expansion chip. ### Changed -- **Mapper modules are named for the board they emulate, not the sprint that - added them.** Eleven `sprintN.rs` files (27,631 lines, ~110 boards) named - after a point in the development calendar are replaced by board-named - modules, and every single-mapper file now carries its iNES mapper number as - an `mNNN_` prefix so the directory sorts by mapper: `m000_nrom.rs`, - `m004_mmc3.rs`, `m009_mmc2.rs`, `m069_sunsoft_fme7.rs`, `m085_vrc7.rs`, and - so on. Files that implement **one** shared core spanning many mapper IDs keep - a plain descriptive name, because no single number describes them — - `mmc3_clones.rs` (11 IDs), `multicart_discrete.rs` (27), `bmc_simple.rs` (7), - `kaiser.rs` (6), `sachen_8259.rs`, `ntdec.rs`, `waixing.rs`, - `sachen_discrete.rs`, `homebrew_boards.rs`, `jaleco_discrete.rs`. - - Boards that were merely *adjacent* are now separate files even where a doc - argument could be made for pairing them: MMC2 and MMC4 share the tile-fetch - CHR-latch concept but not a line of code, so they are `m009_mmc2.rs` and - `m010_mmc4.rs`, consistent with the pre-existing `m001_mmc1.rs` / - `m004_mmc3.rs` / `m005_mmc5.rs`. Likewise VRC2 and VRC4, which share only the - small `vrc_a_bits` pin-rewiring helper — now duplicated per file, exactly as - the crate already duplicates `nametable_offset` across ~40 modules. - - Every new module gains a hand-written `//!` preamble explaining what the - board *is* and why it is shaped that way — MMC2's mid-scanline CHR swap and - why Punch-Out!! needs it; why three mapper numbers describe one VRC4; - Bandai Oeka Kids latching CHR bits off the *PPU* address bus; why the FDS - conversion boards carry a free-running IRQ counter. - - **This moves code; it does not change it.** Verified mechanically rather than - asserted: all **499** top-level items from the eleven sprint files and all - **431** from the pre-existing mapper files compare **byte-identical** in code - (comments excluded, since module docs were deliberately rewritten), with zero - missing and zero altered; the `parse()` dispatch table still resolves the - same **172** mapper IDs to the same constructors, an identical set. Test - count moves 696 → 701 only because five tests that each exercised two-to-four - different boards were split into per-board tests, so a failure now names the - board. - - Also renamed for the same reason: `tests/roms/sprint-2/` → - `tests/roms/assorted/` (a mixed blargg/kevtris corpus, not a sprint), and - `m78.rs` → `m078_irem_jaleco78.rs` (every peer uses a vendor name). - -- **`PPU_SNAPSHOT_VERSION` 7 → 8 — this breaks existing `.rns` save states.** - The `.rns` container is version-exact per section, so a pre-v8 save now fails - to load with a clear `VersionMismatch` instead of silently misreading (ADR - 0028). Accepted deliberately: the alternative is loading states that restore - a broken sprite-evaluation FSM. Movies (`.rnm`) and netplay are unaffected — - both re-derive state from a fresh power-on. `Ppu::restore` still upconverts - v1..=7 blobs for direct callers. -- The scanline-classification cache (`cached_visible` / `cached_pre_render` / - `cached_render_line`, keyed by `flags_cached_scanline`) is now invalidated on - every restore rather than left warm. It is derived from `scanline` + `region`, - both serialized, so this adds no bytes; it stops a cache filled under one - timeline from satisfying the fast dot path's staleness guard under another. - -- **Release builds now ship the PGO binary on `x86_64-unknown-linux-gnu`.** The - `PGO` workflow has computed a profile-guided-optimized build behind a - >3%-faster **and** byte-identical gate since v1.2.0 — but nothing consumed the - result: it ran on the release tag, promoted an artifact, and `release.yml` - attached the plain build regardless, so the measured win never reached a - single user. `release.yml` now *calls* the PGO workflow and replaces the Linux - asset with the promoted binary under the same asset name. - - A PGO gate verdict — slower than the 3% bar, or an oracle divergence — never - blocks or reddens a release: the determinism step is now step-level - `continue-on-error`, so the gate reports `promotable=false`, and the - replacement job is skipped, leaving the plain asset the build matrix already - attached. (A PGO *infrastructure* failure does still mark the run red, which - is intended — a broken PGO pipeline should be visible. The release assets are - correct regardless.) `continue-on-error` cannot be used on the caller job - itself: GitHub disallows it on a reusable-workflow `uses:` job, which - `actionlint` catches. Because the plain archive lands in ~10 minutes and PGO - takes up to 90, the release is complete and downloadable immediately and is - then upgraded in place — deliberately preferred over withholding the whole - release for an hour and a half. - - Scope is **linux-x86_64 only**: PGO training has to *run* the instrumented - binary, so each further target needs its own native runner doing a full - ~90-minute train cycle. macOS-aarch64 and Windows keep shipping plain - release builds. - - Two latent bugs were fixed in passing, both of which this wiring would have - tripped over: the PGO workflow read `github.event.inputs.frames`, which is - empty on `workflow_call` and would have silently dropped the caller's value - (now `inputs.frames`); and the BOLT job's condition admitted any non-dispatch - event, so it would have fired on every release, adding ~90 minutes for an - artifact nothing consumes (now explicit `workflow_dispatch` + `run_bolt` - only). The workflow's own `push: tags` trigger was removed so a hand-pushed - tag no longer starts two 90-minute PGO runs. - -- **CI gained a relative frame-time regression gate** - (`scripts/bench_relative_check.sh`), alongside — not replacing — the existing - absolute ceiling. It builds and benches the base commit and HEAD **back to - back on the same runner** and fails if HEAD is more than 10% slower - (`BENCH_MAX_REGRESSION_PCT`). - - The ceiling answers "is the emulator still real-time?", not "did this change - make it worse": at the ~4 ms/frame the core actually runs at, a change could - get **2.5x slower and still pass**. v1.6.0 judged a percentage gate too flaky - for shared runners, and that was correct for *cross-run* comparison — but a - same-runner back-to-back A/B makes runner variance common-mode, which is the - technique `pgo.yml` has used for its >3% bar since v1.2.0 and which measured a - ±0.7% noise floor during this pass. The 10% default sits far above that on - purpose: this gate is for gross regressions, not 2% micro-optimizations. - - The base commit is benched in a throwaway **git worktree**, never via - `git checkout`, so the gate cannot disturb the tree it runs in; and it **skips - with exit 0** rather than inventing a verdict when no base is resolvable - (shallow clone, root commit, new branch, `workflow_dispatch`). The `bench` job - now checks out with `fetch-depth: 0` so the normal case does not skip. - -- **`rustynes-mappers` with `mapper-audio` compiled OUT was broken, and is now - gated in CI.** `Namco163Audio` was missing the feature-off `clock()` shim that - the NSF expansion router calls unconditionally, so the whole configuration - failed to build with a hard `E0599`. Nothing noticed because nothing built it: - every other feature gate turns features *on*, and the `no_std` job is - `-p rustynes-core`, which keeps `mapper-audio` on. - - The shim is added (matching the pattern FDS and Sunsoft 5B already had), and - `cargo clippy -p rustynes-mappers --no-default-features --all-targets -D - warnings` joins the `lint` job so the subtraction case cannot rot again. - - Two follow-ons the newly-compiling config exposed, neither of which is - unfinished work — the default build is dead-code-warning clean and every item - has real call sites (`effective_period_p/_s` drive VRC6 period computation, - `half_period` reloads the 5B tone/noise counters). They are audio-support - items that are simply unreachable once the subsystem is compiled out, so they - carry `#[cfg_attr(not(feature = "mapper-audio"), allow(dead_code))]` rather - than `#[cfg]` — they still compile, so any future non-audio caller keeps - working. The feature-off shims themselves also needed narrow - `clippy::unused_self` / `needless_pass_by_ref_mut` allows: a shim must keep - the gated signature so the unconditional caller compiles, which is exactly - what those lints object to. - -- **`actionlint` is now clean across every workflow** (it was not before, which - is how the invalid `continue-on-error` above was caught). Two pre-existing - findings fixed: - - `ios.yml` used `ls -d … | sort | tail -1` to pick an Xcode 26 toolchain - (shellcheck SC2012). Replaced with shell globbing, preserving the ordering - exactly — including the non-obvious part, that `Xcode_26.app` outranks - `Xcode_26..app` because `a` sorts above any digit after the shared - prefix. Verified equivalent against six synthetic runner layouts (canonical - only, canonical + point releases, point releases only, none, unrelated - versions, and the documented 26.9/26.10 lexical bound). - - The `agy` self-hosted runner label in `antigravity-review.yml` was reported - as unknown. Declared in a new `.github/actionlint.yaml`, which is the - mechanism actionlint's own diagnostic points at; the alternative (a hosted - label) is not available, since that runner holds the `agy` CLI's OAuth - session. +- **Mapper modules renamed for the board they emulate** (`sprintN.rs` → `mNNN_.rs`, + 27,631 lines, ~110 boards) — proven content-preserving by a byte-for-byte item + comparison (930 items, 0 altered) and an identical 172-ID dispatch table. +- `PPU_SNAPSHOT_VERSION` 7 → 8 **breaks pre-v8 `.rns` save states** (clear + `VersionMismatch`, per ADR 0028); movies/netplay re-derive from power-on and are + unaffected. ### Added -- **Zapper beam-relative light model (A3), default off.** The photodiode's - ~19-26-scanline hold is now modelled: `Nes::set_zapper_temporal_light` makes - the light bit a function of where the CRT beam is at the moment of the - `$4016`/`$4017` read — dark before the beam paints the aim row, lit for the - hold, dark once drained. The frame-granular model structurally cannot express - this; it returns one answer for the entire frame. - - It holds **no extra state** — light is derived on demand from - `(framebuffer, aim, scanline)` — so it adds nothing to serialize and cannot - desync a save state or a netplay rollback, and both models share one aperture - test so they differ only in *when* they sample. It stays opt-in because no - redistributable pass/fail light-gun ROM exists to adjudicate it, and the - supported titles re-poll every frame and are satisfied by either model: - promoting it would change output with no oracle able to confirm the change is - an improvement. - -- **`ppu-idle-line-fast` cargo feature (default OFF)** — a second PPU dot-path - specialization covering *idle lines* (post-render 240 + vblank 242..=260; - 6,820 of the 89,342 NTSC dots), where the per-dot body provably reduces to - three assignments. It is byte-identical — proven by `fast_dotloop_diff`, - extended with `idle_line_fast_path_matches_exact_under_vblank_io`, a - purpose-built NROM that hammers `$2000`/`$2001`/`$2006`/`$2007` throughout - vblank so the guard's fall-through arms are exercised rather than assumed. - It ships **off** because it does not clear the project's >3% adoption bar: - a same-session A/B (±0.7% noise floor) measures −1.3%/−1.5% on - rendering-*disabled* content but +0.2%/+0.4% on the rendering-heavy case that - dominates real play. Kept behind a compile-time flag rather than deleted — - the code is proven and becomes worthwhile if per-dot dispatch gets cheaper; - compile-time rather than runtime because the cost *is* the per-dot guard. - Full measurement, and the contaminated first A/B that nearly got it deleted, - in `docs/performance.md` §P2. - -- Desktop setting `[emulation] fast_dotloop` (Settings → Accuracy, labelled - "performance, not accuracy") as an escape hatch for the fast-dot-path - promotion under **Performance** above — there is no accuracy reason to - disable it. Defaulted through `default_fast_dotloop()` - rather than `#[serde(default)]` so an existing on-disk config loads as `true` - instead of silently opting the user out of an ~11% speedup; pinned by - `emulation_fast_dotloop_defaults_on_for_pre_v2_2_3_configs`. The libretro core - and the mobile bridge inherit the win from the core default and deliberately - gain no new option — neither exposes a comparable knob today. - -- **Save-state schema audit as a standing test** - (`crates/rustynes-test-harness/tests/snapshot_schema_audit.rs`). Every field - of `Ppu` / `Cpu` / `Apu` must be touched by that chip's `snapshot` writer or - appear on an exclusion list with a written reason. The same bug has now - shipped three times — a live mid-frame field added to a chip struct without - the serializer (the v5 ALE fetch state, the v6 sprite-shifter/OAM-corruption - state, the v8 sprite-evaluation FSM) — because no straight-`run_frame` test - can see an incomplete schema; only run-ahead, netplay rollback and TAS - seeking round-trip mid-frame. This audit is a text diff over `include_str!`'d - sources: no ROM, no emulation, runs in the default `cargo test` job, and - would have caught all three at the commit that introduced them. It scopes the - search to the writer body (a whole-file search is satisfied by `restore`'s - own upconvert defaults, i.e. by the bug itself) and matches on word - boundaries; both properties are pinned by their own tests, and the writer - scoping was confirmed by negative control. -- **`APU_SNAPSHOT_VERSION` 3 → 4** — serializes the scheduled warm-reset - `$4017` re-write (`reset_4017_delay` + `reset_4017_value`, 2 bytes), the - first gap the schema audit above found on its own rather than after a bug - report. `Apu::reset` arms the countdown at 2 and `tick_with_external` - decrements it once per CPU cycle, issuing the frame-counter write on zero; a - snapshot taken inside that 2-cycle window used to restore `delay = 0` and - cancel the re-write, leaving the restored sequencer in the phase the - re-write exists to reset. Narrow window and no symptom was ever attributed - to it, but it is the same class as the PPU's v5/v6/v8 tails. Pinned - behaviourally, not just as a field round trip, by - `a_reset_survives_a_snapshot_restore_taken_mid_countdown`. v1..=3 blobs - upconvert to "no re-write pending". No additional compatibility cost: the - `.rns` container is version-exact per section and the PPU v8 tail in this - same change already rejects pre-existing save states. -- Antigravity PR reviewer (`.github/workflows/antigravity-review.yml` + - `scripts/agy-review.sh`): an automated first-pass code review on a self-hosted - runner, driven by the `agy` CLI's OAuth session (Google AI Ultra, no metered - API key). Runs on PR open/reopen and on an `/agy-review` comment from a - contributor with write access, and replaces its own prior comment each run. - Review priorities live in `.github/agy-review.md`. CI-only — no crate, no - shipped artifact, and no emulation-core change. +- **Two optimizations measured and REJECTED, documented with their numbers** + (`docs/performance.md`): `ppu-idle-line-fast` (made the shipped default slower — off) + and the P4 `cpu_clock` levers (already implemented; remaining lever ≤1.9%). +- A **save-state schema audit** standing test (`snapshot_schema_audit.rs`) that fails if + a chip field is added without its serializer — the mechanical net that found the v8/v4 + gaps above. +- An opt-in **Zapper beam-relative light model** (default off; no pass/fail light-gun ROM + exists to adjudicate it) and the Antigravity self-hosted PR reviewer (CI-only). -### Security - -- The reviewer executes on the maintainer's own hardware with a token in scope, - so its trigger and execution paths are gated accordingly: fork PRs cannot - schedule the job, the automation scripts are checked out from the default - branch rather than the PR head (a PR cannot rewrite its own reviewer), `agy` - is launched with `GH_TOKEN`/`GITHUB_TOKEN` removed from its environment, the - `script(1)` fallback quotes its argv with `printf %q` instead of interpolating - env-settable flags into a shell string, the conversation-database fallback is - removed outright (agy's store is shared per-user, so it could copy an unrelated - session into a public comment, and agy exposes no per-invocation store to scope - it to), and prior-comment cleanup is restricted to comments authored by the - workflow's own bot. The trust boundary is "`agy` only ever sees a same-repo - diff", enforced by two checks because neither trigger is covered by one: the - workflow rejects fork PRs on `pull_request`, and the script rejects them again - on the `issue_comment` path, where the payload carries no head-repo field and - `/agy-review` on a fork PR would otherwise feed in an external diff. - Authorizing the commenter is not the same as trusting the diff. `agy --sandbox` - is explicitly *not* part of that boundary — upstream reports it can be - auto-approved away — and the invocation site says so. +Full detail: the GitHub Release and `.github/release-notes/v2.2.3.md`. ## [2.2.2] - 2026-07-21 - "Conduit" (libretro buildbot 10/10 + CI supply-chain hardening + single-source toolchain) -A **build, distribution, and CI-integrity patch**. It carries RustyNES onto -RetroArch's own buildbot — the recipe now builds **all ten platform jobs**, -after a three-round diagnosis against a third-party pipeline we cannot push to -or re-run — hardens the GitHub Actions supply chain, and collapses the -toolchain to a single pinned source of truth with no `nightly` on any build -path. - -**Zero emulation-core changes.** No file under `crates/rustynes-{cpu,ppu,apu, -mappers,core}` is touched, so the deterministic `#![no_std]` chip stack, -save-state / TAS / netplay-replay formats, and every golden vector are -untouched by construction: **AccuracyCoin holds 141/141 (100.00%)**, nestest -stays 0-diff, and `blargg_apu_2005` / `pal_apu_tests` (10/10) / -`visual_regression` / the 60-ROM commercial oracle are all unchanged from -v2.2.1. - -One behavioural improvement does reach a shipped artifact: the libretro **tvOS** -core is now built with `panic = "abort"` like every other platform, rather than -the `panic = "unwind"` its previous `-Zbuild-std` path forced. +A **build, distribution, and CI-integrity patch**. **Zero emulation-core changes** — no +file under `crates/rustynes-{cpu,ppu,apu,mappers,core}` is touched, so **AccuracyCoin +holds 141/141 (100.00%)** by construction, nestest 0-diff, and `pal_apu_tests` 10/10 / +`visual_regression` / the 60-ROM oracle are unchanged from v2.2.1. The one behavioral +improvement in a shipped artifact: the libretro **tvOS** core now builds with +`panic = "abort"` like every other platform. -### Added +### Fixed -- Libretro buildbot CI recipe (`.gitlab-ci.yml`, issue #311) covering Windows - x64, Linux x64, macOS x64/arm64, Android (4 ABIs), iOS arm64, and tvOS - arm64 — the missing piece to get RustyNES onto RetroArch's built-in core - downloader (the repo was already integrated with the legacy - `libretro-super` scripts). Paired with a `[workspace] default-members` - fix so the templates' unscoped `cargo build --release --target ` - builds only `crates/rustynes-libretro`, and a `[lib] name = "rustynes"` - override resolving a compiled-artifact naming collision with the shared - CI templates' fixed `${CORENAME}_libretro` convention. -- Libretro core feature completion: native `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` - registration (the memory-descriptor path RetroAchievements' `rcheevos` - prefers, alongside the existing legacy pointer API); an FDS load-path fix - (`.fds` content is now correctly routed to `Nes::from_disk` with a - `disksys.rom` lookup in the frontend's system directory — previously - broken despite `valid_extensions` advertising it) plus a full disk-control - interface for FDS multi-side swapping via RetroArch's Quick Menu; native - Game Genie cheat support (`on_cheat_set`/`on_cheat_reset`); and a - `get_fastforwarding`-gated audio-push skip during RetroArch's - fast-forward/rollback-netplay catch-up path. +- **Libretro buildbot: 1 of 10 jobs green → all ten building** (the last step before + RustyNES lands in RetroArch's built-in core downloader). Three independent, our-side + defects, all invisible until the buildbot ran: 8 jobs missing cross-compile targets + (each now `rustup target add ${RUST_TARGET}` into the pinned toolchain); the upstream + `rust-libretro 0.3.2` MinGW keycode-signedness bug (worked around by pointing bindgen's + clang at the MSVC triple for the `-gnu` targets); and the tvOS `+nightly -Zbuild-std` + override, now dropped since `aarch64-apple-tvos` ships a complete prebuilt std + (`panic_abort` included). ### Security -- **`persist-credentials: false` on all 19 CI checkouts, plus a fail-closed - release-tag check and a pinned toolchain action (closes #318).** - `actions/checkout` defaults to writing the workflow `GITHUB_TOKEN` into - `.git/config`, where any code the job then executes from the checkout — - Cargo build scripts, proc macros, test binaries, Gradle build scripts, - `scripts/*.sh`, the MkDocs build — can read it. On a pull request that tree - is by definition unreviewed code, and nearly every CI job compiles or runs - it (the exceptions being the `audit` / `deny` jobs, which install prebuilt - binaries and only parse `Cargo.lock`). - Audited rather than applied blanket: `.github/actions/rust-setup` performs - no checkout of its own, so call-site hardening is complete coverage; and - **no job was found to need the checkout credential** — nothing pushes - commits, tags, or branches, there are no submodules, GitHub Pages uses the - OIDC flow (not a `gh-pages` push), and `gh release create` / - `softprops/action-gh-release` authenticate by API token while - `fastlane match` clones a different remote with its own `MATCH_GIT_*` - credentials. Highest-exposure site was not a `ci.yml` job but `web.yml`'s - `build`, whose workflow-level `permissions:` grant `pages: write` + - `id-token: write` and which is PR-reachable while running `trunk`, - `cargo doc`, `pip install`, and `mkdocs build`. - Two related hardening items landed alongside it: - - `release-auto.yml`'s tag-existence check was - `git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1`, - which collapsed *tag present*, *tag absent*, and *lookup failed* into a - two-way answer, reading any non-zero exit as "absent" — so a transient - network or auth blip pushed an already-released version down the - `should_release=true` path. It is now a `gh api` call against - `git/matching-refs/tags/`, chosen over `git/ref/tags/` because it - answers "absent" with HTTP 200 and an empty array rather than a 404, so a - genuine miss can never be confused with an error and no error-body parsing - is needed. That endpoint matches by prefix, so the exact ref is compared in - `jq` — verified necessary, not theoretical: `v2.2` prefix-matches two real - tags while exact-matching none. Every failure path now aborts the job under - `set -euo pipefail` instead of resolving to a release decision — including - the one shape that slipped through review-round one: an explicit - `type != "array"` guard, because a body of `{}` makes `.[]` iterate zero - object values, so the filter returns `0` and is indistinguishable from a - genuine "tag absent", which takes the *release* path. Removing - the last Git operation is also what let that job's checkout — the only one - that had needed the credential — join the sweep. - - `.github/actions/rust-setup` pinned `dtolnay/rust-toolchain` from `@master` - to commit `e97e2d8c` (`# v1`). `@master` is a **branch** that advances on - every upstream commit, unlike the `@vN` tags used everywhere else, and this - composite feeds 12 of the 19 checkouts — including `release.yml` - (`contents: write`) and `web.yml` (`pages: write` + `id-token: write`) — - while being the action that installs the compiler. The `# v1` trailing - comment is what Dependabot's already-enabled `github-actions` ecosystem - reads to keep the pin current, so this does not trade a supply-chain risk - for a stale-action one. - Purely additive CI configuration — no source, build output, or emulation - behavior changes. +- **`persist-credentials: false` on all 19 CI checkouts** (closes #318) — + `actions/checkout` otherwise writes `GITHUB_TOKEN` into `.git/config`, readable by the + unreviewed PR code nearly every job builds/runs. Audited: no job needs the credential; + the highest-exposure site was `web.yml`'s `build` (`pages: write` + `id-token: write`). +- **The release tag-existence check is now fail-closed** (`release-auto.yml`): a + `gh api git/matching-refs` call that can never confuse "lookup failed" with "tag + absent" (which used to risk re-releasing a shipped version), with an explicit + non-array guard. +- **`dtolnay/rust-toolchain` SHA-pinned** off the moving `@master` branch (it installs + the compiler and feeds 12 of 19 checkouts), keeping the Dependabot-readable `# v1` + marker. ### Changed -- **One toolchain everywhere: `rust-toolchain.toml` is now the single source - of truth for CI.** `.github/actions/rust-setup` parses the `channel` from - that file and installs it, failing closed if it cannot be parsed, so there - is no longer a `toolchain:` version literal anywhere in `.github/` — a - toolchain bump is a one-line edit. Previously the composite defaulted to - `stable` and 5 of its 12 call sites overrode that with an explicit - `1.96.0`. That was misleading rather than broken: `rust-toolchain.toml` is - a directory override and outranks the `rustup default` the action - performs, so **every job was already compiling on 1.96.0** — the `stable` - default merely downloaded a second toolchain nothing used, and made the - workflows read as though they tested against latest stable, which they - never did. With the libretro tvOS job also moved onto the pin (see Fixed), - every build, test, lint, docs, release, and packaging path across GitHub - Actions, the libretro buildbot, and local builds now runs on the same - pinned 1.96.0. Nightly survives in exactly two places, neither of them a - gate: `cargo fuzz`, which requires it for its sanitizer flags, and the - dormant `rustynes-monetization` crate's standalone `uniffi-bindgen` - helper. -- **Dependency version-bump consolidation (closes Dependabot #313–#315).** - Rolled all three open Dependabot PRs into one reviewed change plus a - full `cargo update --workspace` sweep of the rest of the tree, all with - **no source changes** and the deterministic `#![no_std]` core untouched - (AccuracyCoin stays **141/141**): CI **actions/setup-python v6 → v7** - (MkDocs step), **lz4_flex 0.13 → 0.14** (save-state/movie compression; - `default-features = false` + `safe-encode`/`safe-decode` retained, plus - an explicit `alloc` feature request — 0.14 split `alloc`-vs-`std` no_std - support, and without it the `rustynes-core` no_std cross-compile job - fails to compile `compress_prepend_size`/`decompress_size_prepended`), - the `production-dependencies` group (**bitflags 2.13.0 → 2.13.1**, - **bytemuck 1.25.1 → 1.25.2**, **cc 1.2.67 → 1.3.0**, **clap 4.6.1 → - 4.6.2**, **futures-core/-macro/-sink/-task/-util 0.3.32 → 0.3.33**, - **serde_json 1.0.150 → 1.0.151**), and a workspace-wide `cargo update` - picking up **tokio 1.52.3 → 1.53.1** (which also drops its transitive - `windows-sys`/`windows_*` 0.53.x dependency set entirely) and - **clap/clap_derive → 4.6.3**. Surveyed the remainder of the tree via - `cargo outdated --workspace` and confirmed nothing else is actionable: - `getrandom` 0.2 (wasm32) is pinned transitively by `ring` upstream, and - `wgpu`/`naga` 30.0.0 stay out because `egui-wgpu` 0.35.0 (the newest - egui release) still requires `wgpu = "^29.0"` — bumping wgpu alone - would split the tree across two incompatible majors, so the desktop - stack stays in its existing egui 0.35 / wgpu 29 / winit 0.30.13 / - accesskit 0.24.1 tier until egui itself moves. GitHub Actions were all - already pinned to their current major tag and float to the latest - point release automatically. Android/iOS Gradle/Swift dependency - versions are intentionally out of scope here (they are their own - separately-verified trains, per project convention — see the v1.8.8 - "Atlas" and iOS dep-refresh history). Verified with `cargo fmt --check`, - `cargo clippy --workspace --all-targets -D warnings` (+ every feature - combo), and `cargo test --workspace`. +- **One toolchain everywhere: `rust-toolchain.toml`'s `channel` is the single CI source + of truth.** `rust-setup` parses it and fails closed; no toolchain version literal + remains under `.github/`, and **no `nightly` on any build path** (nightly survives + only for `cargo fuzz`). +- **New `libretro-cross` CI job** cross-checks `rustynes-libretro` against the buildbot + ABI families a Linux runner can model (MinGW-Windows, Android/NDK) — the early-warning + gate that was previously absent. +- **Dependabot #313–#315 consolidated** into one reviewed change plus a `cargo update` + sweep, no source changes (lz4_flex 0.13 → 0.14 with an explicit `alloc` feature, + tokio 1.52.3 → 1.53.1, and the production-dependencies group). -### Fixed +### Added + +- The libretro buildbot recipe (`.gitlab-ci.yml`, issue #311) covering all ten platform + jobs, plus libretro core feature completion (native memory-maps for `rcheevos`, an FDS + load-path fix + multi-side disk-control, native Game Genie cheats, fast-forward + audio-skip). -- **Libretro buildbot pipeline: 1 of 10 jobs green → all 10 building.** The - first run of the v2.2.1 `.gitlab-ci.yml` recipe on libretro's GitLab - buildbot ([pipeline #91899](https://git.libretro.com/libretro/RustyNES/-/pipelines/91899)) - passed only `libretro-build-linux-x64`. Three independent, previously - invisible defects — all on our side, none in libretro's build images: - - **Missing cross-compile targets (8 jobs).** `rust-toolchain.toml` pins - `channel = "1.96.0"`, so rustup installs a *fresh* 1.96.0 toolchain in - the build image carrying only the host std plus the two targets that - file declares — bypassing the image's own default toolchain, on which - every libretro cross target is pre-provisioned. Every non-host job died - with `E0463: can't find crate for core`; Linux x64 survived only because - its target *is* the host triple. Each job now runs - `rustup target add ${RUST_TARGET}` into the pinned toolchain. Not solved - by extending `rust-toolchain.toml`'s `targets = [...]`, which would make - every contributor and every GitHub Actions job download ~8 extra - `rust-std` components on each toolchain install. - - **Upstream `rust-libretro` MinGW ABI bug (Windows).** Masked behind the - target failure and never previously reached. `rust-libretro 0.3.2` casts - a keycode to `i32` under `cfg(target_family = "windows")`, but C enum - signedness follows the *ABI*, not the OS family: only the **MSVC** ABI - gives plain enums `int`. Under **MinGW** — which is what the buildbot's - `x86_64-pc-windows-gnu` job uses — bindgen emits `retro_key(c_uint)`, so - the crate fails with `E0308`. Upstream has had no commit since 2023-02 - and 0.3.2 is its newest release, so `.cargo/config.toml` now points - bindgen's clang at the matching MSVC triple for **both** MinGW targets - — `x86_64-pc-windows-gnu` (the buildbot) and `i686-pc-windows-gnu` - (32-bit Windows via this crate's `Makefile`, the legacy libretro-super - path), which was verified to fail identically. Surgical: the - generated bindings differ by 28 lines, all enum signedness, with no - struct layout, signature, or type size affected. - - **tvOS `panic_abort` + MSRV (tvOS).** Its template overrides `script` - with `cargo +nightly build -Zbuild-std`, which bypasses our channel pin - onto the image's stale 1.94.0-nightly — below the workspace's - `rust-version = "1.96"`, failing cargo's MSRV gate before compiling - anything. Refreshing the nightly channel exposed a second issue: bare - `-Zbuild-std` does not build `panic_abort`, which - `[profile.release] panic = "abort"` requires. Both are handled in the - job (the template hardcodes the flag, so neither is fixable by argument). - - **New `libretro-cross` CI job** (`.github/workflows/ci.yml`) cross-checks - `rustynes-libretro` against the buildbot ABI families a Linux runner - can model faithfully — MinGW-Windows and Android/NDK. The Apple - families are excluded on purpose: bindgen needs a real per-target - sysroot, there is no Apple SDK on a Linux runner, and feeding it host - glibc headers would generate Apple bindings from Linux headers — a - lookalike rather than a rehearsal. There was previously - *zero* libretro coverage in GitHub Actions, which is why all three - defects reached a third-party buildbot we cannot push to or re-run. -- **Libretro buildbot: the last failing job (tvOS) now builds on the pinned - stable toolchain, dropping three workarounds.** The follow-up run - ([pipeline #91954](https://git.libretro.com/libretro/RustyNES/-/pipelines/91954)) - took the recipe from 1/10 to 9/10, leaving only - `libretro-build-tvos-arm64`. Its upstream template overrides `script` with - `cargo +nightly build -Zbuild-std`, which dates from when - `aarch64-apple-tvos` was a tier-3 target with no distributed `rust-std`. - The target has since been promoted and rustup now ships a complete - prebuilt std for it — `panic_abort` included — so the job now uses the - shared Apple build script (via `!reference`) on the same pinned 1.96.0 as - every other job. That removes all three workarounds the `+nightly` path - had forced, rather than adding a fourth: the nightly-channel reinstall - (needed because `+nightly` outranks the channel pin and the image's - 1.94.0-nightly is below the workspace `rust-version = "1.96"`); the - `CARGO_PROFILE_RELEASE_PANIC=unwind` override (needed because bare - `-Zbuild-std` omits the `panic_abort` that `[profile.release] panic = - "abort"` requires, and the hardcoded flag cannot be overridden by - `CARGO_UNSTABLE_BUILD_STD`); and a clearing of the image-injected - `-Car=,Clink-arg=...`, whose long-deprecated `-C ar` became a **hard - error in Rust 1.97** (bisected: 1.96.1 warns, 1.97.1 errors) and so broke - the refreshed nightly. tvOS now honours `panic = "abort"` exactly like - every other platform. All four Apple jobs still receive the `-C ar` flag - and are green only because 1.96.0 treats it as a warning, so - `rust-toolchain.toml` carries a warning for whoever bumps that pin to - 1.97+. +Full detail: the GitHub Release and `.github/release-notes/v2.2.2.md`. ## [2.2.1] - 2026-07-15 - Housekeeping patch (dev-tooling archival + dependency consolidation + FDS test corpus) @@ -1394,300 +917,85 @@ untouched. AccuracyCoin holds **141/141 (100.00%)**, unchanged from v2.2.0. ## [2.1.6] - 2026-07-11 - "Fathom" (expansion audio — decibel oracle + hardware/Mesen2 channel-level calibration + Namco 163 12 dB fix + mix UI/scopes; "Timbre") +An expansion-audio fidelity cut. **Base 2A03 NTSC output stays byte-identical** — +expansion audio is a separate additive `mix_audio` term — so AccuracyCoin holds +**141/141**, `blargg_apu_2005`, nestest, and `visual_regression` are unchanged; only the +three `db_vrc6`/`db_mmc5` expansion snapshots were re-blessed (audio hash only). + ### Added -- **Marquee CRT shader stack + raw NTSC composite signal (v2.1.9 "Presentation - & Signal").** A presentation/display cut, all opt-in and **default - byte-identical** (the shipped presentation is untouched, so `visual_regression` - stays byte-identical and AccuracyCoin holds **141/141**). - - **Raw composite core (P4).** A new `rustynes-ppu::raw_signal` module that - keeps the 2C02 composite waveform *un-decoded*: for every `(index, emphasis)` - pair it emits the twelve per-subcarrier-phase voltages the chip actually - generates, so a decoder can reproduce signal-domain artifacts a per-colour - palette cannot — composite colour bleed, dot crawl, and the waterfall/dither - transparency tricks. Follows the canonical Bisqwit `nes_ntsc` / Mesen2 "raw - palette" model; `generate_raw_signal_lut()` yields the full 512×12 table a - host uploads as a signal texture. No transcendental in the path, so it is - `f32` byte-identical across x86 / aarch64 / wasm / `thumbv7em` (a `no_std` - `GOLDEN_SIGNAL` cross-target lock guards it). Additive + default-OFF: the - core, the default framebuffer, and AccuracyCoin are unaffected. - - **CRT shader stack (B6).** Three single-pass WGSL ports of the reference - libretro *slang* CRT presets, added as **new WGSL files** in - `rustynes-gfx-shaders` behind a `CrtStackShader` registry: **CRT-Royale** - (luminance-scaled Gaussian beam, selectable aperture/slot/shadow mask, - gamma-correct scanlines, curvature), **CRT Guest Advanced / guest-dr-venom** - (power-shaped beam, halation glow, mask, curvature), and **Sony Megatron** - (per-subpixel phosphor lighting with an HDR headroom + SDR Reinhard - fallback). All four new shaders — the three CRT plus the P4 signal-decode - pass — are gate-validated as real, compilable WGSL by the same **naga** - front-end + validator wgpu runs at pipeline creation. - - **Raw NTSC signal-decode pass (P4 shader).** `signal_decode.wgsl` - reconstructs the 2C02's actual two-level chroma square wave from the - palette-index framebuffer (matching `raw_signal.rs` byte-for-byte) and - demodulates it with a windowed quadrature filter — decoding the true signal - rather than re-encoding already-decoded RGB. - - Display suite: the CRT shaders expose curvature / mask-type / scanline-weight - uniforms and build on the existing 8:7 PAR correction, overscan crop, and - hqNx/xBRZ integer-style scaler foundations already in the tree. - -- **Expansion-audio mix UI + per-channel visualization (v2.1.6 "Expansion - Audio").** A dedicated **Audio Mixer** tool panel (Tools → Audio Mixer) - unifying per-source mix balance with live per-channel visualization for any - ROM — cartridge audio, not just `.nsf` tunes. Per-source gain sliders - (`0.0`–`2.0`) + mute toggles for the five base 2A03 channels (pulse 1/2, - triangle, noise, DMC) and the on-cart **expansion** channel, which is enabled - and labelled with the detected chip family (VRC6 / VRC7 (OPLL) / MMC5 / Namco - 163 / Sunsoft 5B / FDS). Sensible **presets** — `Authentic (HVC-001)` (unity), - a Mesen-style `Balanced` rebalance (tames a hot expansion chip vs the 2A03), - and `Expansion boost` — plus a reset-to-unity. Per-channel **oscilloscope** - traces and peak **VU meters** (master + all six sources), including a new - read-only expansion-audio display tap (`ApuDebugView::external` / - `Apu::external_out()`). The NSF player panel gains the same expansion scope/VU, - and the scope/VU primitives are factored into a shared module reused by both. - **The mix is a frontend re-weight, not a synthesis change**: it drives the - existing determinism-safe `channel_gain` / `channel_mask` core overlay, which - is byte-identical at unity and is never serialized into the save state — so a - save-state / TAS / netplay replay stays byte-identical regardless of the - slider positions, and the visualization samples a read-only copy that never - feeds back into synthesis. AccuracyCoin holds **141/141 (100%)**. -- **Expansion-audio decibel oracle (v2.1.6 "Expansion Audio").** Upgraded `crates/rustynes-test-harness/tests/audio_expansion.rs` from pure `insta` snapshots into a real accuracy oracle: each bbbradsmith `db_*` comparison ROM now has a machine-verifiable level criterion. The new `level_db_*` tests measure the peak amplitude of the reference-2A03-square and expansion-square segments in the rendered waveform (`common::capture_frame_peaks` over deterministic frame windows) and **assert** the expansion/reference ratio against the Mesen2 / hardware target — APU triangle ÷ square ≈0.524, VRC6 ≈1.506, MMC5 ≈1.000, N163 1-channel ≈6.02. The 19 `insta` snapshots are retained as byte-exact regression guards. -- VRC7 instrument-ROM verification: `vrc7_all_15_melodic_patches_match_nuke_ykt_canonical` pins all 15 melodic patches (+ 3 rhythm) to the canonical Nuke.YKT dump (byte-identical across fceux / Mesen2 / nestopia) — the real `patch_vrc7` criterion. Sunsoft 5B log-DAC step-law and Namco 163 long-period (256-sample) wavetable unit tests added. -- **Vs. `DualSystem` presentation in the libretro core (v2.1.10 "Web Parity").** - The libretro core (`crates/rustynes-libretro`) now presents Vs. `DualSystem` - arcade cabinets (Balloon Fight / Wrecking Crew / Tennis / Baseball), reaching - parity with the desktop frontend. It detects them with the same `Emu::from_rom` - (NES 2.0 header Vs. type OR the SHA-keyed `vs_db`), steps **both** cross-wired - consoles each `retro_run`, and composes their two 256×240 framebuffers into a - single **512×240** XRGB8888 side-by-side image (MAIN left, SUB right) — presented - within a 512-wide `max_width` geometry so RetroArch draws the variable width with - no geometry renegotiation. Libretro ports 0/1 → MAIN P1/P2, 2/3 → SUB P1/P2; only - MAIN audio plays; save states use `VsDualSystem::snapshot`/`restore`; the RA/cheat - memory maps expose the MAIN console. Previously a `DualSystem` dump booted a single - console that hangs on its absent partner. The deterministic `no_std` core is - untouched and byte-identical — this is a parallel present/serialize branch in the - FFI wrapper. **Code-complete + builds** (`cargo build -p rustynes-libretro`); - a live RetroArch run with a real cabinet dump is the maintainer's manual check. - Docs: `docs/libretro/advanced_features.md`, `docs/frontend.md`. -- **Browser RetroAchievements auth-proxy deploy stack (v2.1.10 "Web Parity", ADR - 0015).** The browser-RA marshalling (`ra_glue.js` + `wasm_cheevos.rs`) has shipped - since v1.7.0; this lands the remaining ADR 0015 carryover's **deployable** half — - the casual-only auth proxy that injects RA's identity `User-Agent` server-side - (browsers forbid scripts from setting it). `deploy/` gains a first-class - `ra-proxy` compose service (`deploy/Dockerfile.raproxy`, running the stdlib-only - reference stub) behind the shared Caddy TLS proxy at `https:///ra/*`, - configured **purely from env** (`RA_USER_AGENT` / `RA_ALLOWED_ORIGINS` / - `RA_UPSTREAM` / `RA_ENFORCE_CASUAL`) — the proxy holds no RA secret. The stub - grew env-var configuration so one script serves both local dev and the container. - Marshalling-contract tests added to `wasm_cheevos.rs` (ACHIEVEMENT_TRIGGERED - filtering + malformed-payload tolerance + the not-configured caveat). - **Code-complete + compose/config validated**; standing the stack on a live host, - the RA-team `User-Agent` coordination, and a real browser RA login + casual unlock - are the un-CI-able acceptance gate (runbook: `deploy/README.md`, - `docs/cheevos-browser.md`). +- **Expansion-audio decibel oracle** (`audio_expansion.rs` `level_db_*`) — each + bbbradsmith `db_*` ROM now asserts the measured expansion/reference peak ratio against + the Mesen2 / hardware target (triangle ÷ square ≈0.524, VRC6 ≈1.506, MMC5 ≈1.000, N163 + 1-ch ≈6.02), upgrading the prior byte-exact `insta` snapshots into a real level oracle. +- **Audio Mixer panel** (Tools → Audio Mixer) — per-source gain sliders + mutes for the + five 2A03 channels and the detected on-cart expansion chip (VRC6/VRC7/MMC5/N163/5B/FDS), + presets (Authentic / Balanced / Expansion boost), and per-channel oscilloscope + VU + meters. A frontend re-weight of the determinism-safe `channel_gain`/`channel_mask` + overlay (byte-identical at unity, never serialized). +- **VRC7 patch-set verification** — all 15 melodic (+3 rhythm) patches pinned + byte-identical to the canonical Nuke.YKT dump; plus Sunsoft 5B log-DAC and Namco 163 + long-period wavetable unit tests. ### Changed -- **Expansion-audio channel levels calibrated to the hardware / Mesen2 db_* levels.** VRC6 square `256 → 979` (`VRC6_MIX_SCALE`, ≈0.39× → ≈1.51× the 2A03 pulse), MMC5 pulse/PCM `256/16 → 650/40` (≈0.39× → ≈1.0×, "equivalent to the APU" per hardware), and **Namco 163** `64 → 261` (`NAMCO163_MIX_SCALE`, ≈1.48× → ≈6.02× for 1-channel mode — no reference emulator attenuates N163; ours was ~12 dB too quiet). The N163 fix is bit-shared with the NSF playback path. **Base 2A03 NTSC output stays byte-identical** — expansion audio is a separate additive `mix_audio` term (0 for non-expansion mappers), so AccuracyCoin (141/141), `blargg_apu_2005`, `nestest`, and `visual_regression` are unchanged; only the three `db_vrc6a/b`/`db_mmc5` expansion snapshots were re-blessed (audio hash only, provably more accurate). +- **Expansion-audio channel levels calibrated to the hardware / Mesen2 `db_*` levels:** + VRC6 `256 → 979` (≈0.39× → ≈1.51× the 2A03 pulse), MMC5 `256/16 → 650/40` (≈1.0×), and + **Namco 163 `64 → 261`** (≈1.48× → ≈6.02× 1-channel — it was ~12 dB too quiet; no + reference emulator attenuates N163). The N163 fix is shared with the NSF path. *(VRC6 + was later re-corrected to ~1.0× in v2.2.7.)* ### Deferred (documented) -- **Vs. `DualSystem` on the wasm desktop-style present (v2.1.10 "Web Parity").** - The CPU compositor (`Gfx::compose_dual_into`) and the core (`Emu::Dual`) are - already cross-platform, but the wasm ROM-load detection + un-gating the GPU - present branch (`Gfx::render_dual`, currently `cfg(not(wasm))`) are deferred to - the v2.1.8/v2.1.9 gfx/composite rebase to avoid colliding with that concurrently - rewritten present path. The libretro dual present ships now (see Added); mobile - stays deferred. See `docs/frontend.md`. -- **Sunsoft 5B absolute level** and **VRC7 FM level** are honest documented gaps (`docs/accuracy-ledger.md` §Expansion-audio levels): the 5B log-DAC *shape* is hardware-exact but its full vol-15 / 3-simultaneous-tone range overflows the `i16` `mix_audio` contract (needs a wider mix path); the VRC7 OPLL FM synth + patch ROM are correct, but the pseudo-sine absolute level is patch-dependent and has no clean square-vs-square oracle. Both stay snapshot-guarded. +- Sunsoft 5B absolute level and VRC7 FM level remain honest documented gaps + (`docs/accuracy-ledger.md`) — the 5B needed a wider-than-`i16` mix path (closed in + v2.2.3); the VRC7 FM level has no clean square-vs-square oracle. ## [2.1.5] - 2026-07-11 - "Fathom" (regression net & residual — Holy Mapperel mapper regression net + PAL APU frame-counter 10/10 + real TURN NAT-retransmit production fix + fat-LTO A/B validation + MMC3 F5.0 A12-phase study; "Vernier") +A regression-net and residual cut. Additive/observational and NTSC-byte-identical — +AccuracyCoin holds **141/141**, nestest 0-diff, the commercial byte-identity oracle +unchanged. + ### Added -- **Mapper bank-reachability + IRQ regression net (v2.1.5 "Regression Net & - Residual").** Wired the tepples **Holy Mapperel** cartridge-PCB-assembly test - ROMs into CI as a dedicated mapper regression net - (`crates/rustynes-test-harness/tests/holy_mapperel.rs`, gated on the default - `--features test-roms`). Holy Mapperel detects which mapper it is running on - purely from the console's mirroring + bank-switching response (no header - trust), sizes PRG/CHR ROM/RAM, proves every PRG/CHR bank is reachable, and - exercises WRAM + the MMC3/FME-7 interval-timer IRQ — coverage the - `AccuracyCoin` and blargg CPU/PPU corpora barely touch and the gitignored - 60-ROM commercial oracle can't provide in CI. Because Holy Mapperel reports - its verdict visually (no blargg `$6000` status protocol), each of the 17 - committed zlib-licensed ROMs is driven to its settled result screen and pinned - by an `insta` framebuffer-hash snapshot (the same determinism-backed technique - `visual_regression` uses), with two structural guards — *settled* (byte-stable - across a late frame window, so a Morse-code hard-crash never green-lights) and - *non-blank* — running first so a hard fault surfaces with a ROM-named message. - The suite is data-driven over the committed ROM directory, so a newly-added - ROM auto-enrolls (new snapshot line + a forced `UNVERIFIED` classification). - 15 of 17 ROMs detect the correct mapper and reach every bank with detailed - code `0000`; the two MMC1 (`M1_*`) and two FME-7 (`M69_*`) ROMs surface a - documented, honestly-pinned **WRAM-protection residual** whose cause differs - per mapper. MMC1 genuinely does not model its software WRAM write-protect: - `mmc1.rs` accesses `$6000-$7FFF` `prg_ram` unconditionally and ignores the - `$E000`/`$A000` bit-4 disable, so the driver flags `1000` (SJROM, `$E000` - layer) / `5000` (SNROM, both layers) — a widely-shared simplification (Holy - Mapperel's own README notes FCEUX / PowerPak omit it, and modelling MMC1 - RAM-disable is a known game-compat hazard). FME-7 is *not* an - always-enabled-WRAM case: it **does** model the command-`$8` RAM-enable - (bit 7) / RAM-select (bit 6) bits — `sprint3.rs` maps PRG-RAM only when both - are set and PRG-ROM when RAM is deselected — so its `1000` is a narrower gap: - the "RAM selected but disabled" state (bit 6 = 1, bit 7 = 0) should read back - as **open bus**, but RustyNES falls through to the last PRG-ROM bank; the - driver's third "read open bus" sub-check reads the last-bank tag (`1`, below - its `>= 3` open-bus threshold) and flags the WRAM-enable nibble. The FME-7 - IRQ nibble is `0` (the interval-timer IRQ works), and neither case is a - bank-reachability defect (every bank is reachable). The net is purely - additive: it changes no core behavior, so `AccuracyCoin` (141/141) and the - commercial byte-identity oracle stay unchanged. ROM license provenance - (zlib, Damian Yerrick) is recorded in `tests/roms/LICENSES.md`; the residuals - are recorded in `docs/accuracy-ledger.md`. -- **MMC3 R1/R2 residual A12-phase instrumentation study (v2.1.5 F5.0, ADR - 0002).** A purely-observational, default-off probe feature - (`mmc3-a12-phase-probe`, in `rustynes-mappers` + `rustynes-core` + - `rustynes-test-harness`) plus a reproducible study fixture - (`crates/rustynes-test-harness/tests/mmc3_r1r2_phase_probe.rs`) that answers, - with *fresh direct instrumentation*, the one avenue ADR 0002's F5.0 closure - left open: on the four `#[ignore]`'d MMC3 IRQ residuals, does any *qualifying* - (`gap >= 3`) A12 rising edge that clocks the IRQ counter ever land in the - post-access (M2-high, φ2) half of a host CPU cycle — the sub-cycle window an - ares-style M2-half-cycle low-time filter would treat differently from the - integer `gap >= 3` model? The feature seeds the real M2-phase into the mapper - `sub_dot` on the live one-clock scheduler and *only counts* qualifying rises - by half (no assertion deferral), so the emulated timeline is byte-identical to - the default build; the tallies are surfaced via `MapperDebugInfo.extra`. The - study **refines** the F5.0 finding: the two `scanline_timing` residuals - (`mmc3_test_2/4` #3, `mmc3_test_v1/4` #3) have zero post-access IRQ-clocking - rises — directly confirming Session B's (2026-07-02) indirect byte-identity - result — but the two "reload/set-IRQ-every-clock" residuals (`mmc3_test_v1/5` - #2, `mmc3_test_v1/6` #2), which Session B never tested, have **4** post-access - IRQ-clocking rises each (and *every* qualifying rise post-access). So the - "no post-access rise" premise is ROM-specific, not a structural NTSC-MMC3 - property. Separately, engaging the existing default-off `mmc3-m2-phase-irq` - rising-edge deferral lever on `/5` and `/6` leaves their failure status - byte-identical — it is non-curative. **No production, scheduler, or MMC3 - default behavior changed; AccuracyCoin stays 141/141** and all four residuals - stay `#[ignore]`'d. The ares-style M2-edge low-time *filter* remains the one - genuinely-untested axis-B lever; ADR 0002 records it as an axis-B candidate - deferred to a maintainer decision (see the 2026-07-11 F5.0 decision update) - and `docs/accuracy-ledger.md` is updated with the refined disposition. -- **PAL APU frame-counter step positions + screen-reading oracle (v2.1.5 - "Regression Net & Residual").** Modeled the PAL (2A07) APU frame-counter - sequencer step positions and wired blargg's freely-redistributable - **`pal_apu_tests`** corpus (10 sub-ROMs, PAL-calibrated) into CI as the first - PAL-region APU oracle (`crates/rustynes-test-harness/tests/pal_apu_tests.rs`, - gated on the default `--features test-roms`), forcing PAL region via a - throwaway-header stamp. In wiring it, this **corrects a false oracle**: the - prior revision drove these 2005-era ROMs through the `$6000` WRAM status - runner and asserted `status == 0` — but they are plain NROM with *no - PRG-RAM*, so `$6000` reads `0` forever and the check passed vacuously, - claiming "all ten PASS" while validating nothing (the blargg `$DE $B0 $61` - completion magic never even appears). The suite now reads the ROMs' real - **on-screen** verdict (`APU ` then `PASSED` / `FAILED: #<n>`) decoded - from the nametable by the new `run_nes_screen` harness runner, which - early-returns the instant the verdict renders (5-18 frames) and treats a - never-settling screen as a hard failure, never a pass. - - **PAL frame counter (`crates/rustynes-apu/src/frame_counter.rs`).** The - 2A03 (NTSC) and 2A07 (PAL) share the same six-step sequencer but divide the - CPU clock differently, so the identical quarter/half/IRQ events land at - different CPU-cycle counts. `FrameCounter` now carries a `pal` selector, - derived from the console `Region` by `Apu::new` (true only for - `Region::Pal`; NTSC and Dendy keep the NTSC positions). PAL 4-step - (mode 0) clocks at 8313 / 16627 / 24939 / 33252 / 33253 / 33254; PAL 5-step - (mode 1) at 8313 / 16627 / 24939 / 41565 / 41566 (Mesen2 `stepCyclesPal`). - The mode-0 terminal three cycles replicate the NTSC IRQ-flag-visibility / - `irq_line_active` split verbatim at the PAL positions. - - **Result: 10 of 10 pass** (was a vacuous 10/10, honestly 3/10 pre-model) — - the three region-independent checks (`01.len_ctr`, `02.len_table`, - `03.irq_flag`); the five PAL frame-counter-timing checks - (`04.clock_jitter`, `05`/`06.len_timing_mode0`/`1`, `07.irq_flag_timing`, - `08.irq_timing`) that flipped to PASS with the PAL step positions; and - `10.len_halt_timing` / `11.len_reload_timing` closed by the length - halt/reload ordering fix below. - - **NTSC byte-identity preserved (sacred).** The step-position change is - strictly region-gated: the NTSC/Dendy step tables are unchanged and the - power-on / snapshot-restore default is NTSC (the `pal` selector is - *derived*, not persisted — the APU snapshot format is untouched, and - `Apu::restore` re-derives it from the restored region). The halt/reload - ordering change is region-agnostic but byte-identical on NTSC by - construction (see below). Verified byte-identical: AccuracyCoin 141/141 - (100.00%), `apu_test` 8/8, NTSC `blargg_apu_2005` 11/11, `f2_accuracy_audit` - 6/6, `apu_mixer` / `volume_tests` / `visual_regression` unchanged, `nestest` - 0-diff. - - **Length halt/reload write-ordering fix (`crates/rustynes-apu/src/length.rs`).** - Closes `10.len_halt_timing` (was `FAILED: #3`) and `11.len_reload_timing` - (was `FAILED: #4`). The 2A03 applies a length-counter **halt** change and a - length **reload** one step *behind* the frame sequencer's half-frame length - clock: a halt write on the clock cycle governs the *next* clock (not this - one), and a reload on the clock cycle is dropped if the counter was clocked - from a non-zero value. `LengthCounter` now defers both — `set_halt` latches - `new_halt`, `load` latches `reload_val` + a `previous_count` snapshot — and - `LengthCounter::reload` (called on all four length channels once per CPU - cycle in `Apu::tick_with_external`, **after** the half-frame clock and - **before** the mixer sample) promotes the halt and applies the reload only - when the post-clock count still equals the snapshot. Mirrors `TetaNES` - `LengthCounter::reload` and Mesen2's `_newHaltValue` + reload-request. - Because the reload settles in-cycle on the common non-coincident write and - halt does not affect channel output directly, the change is byte-identical - on NTSC — it alters only the exact write-on-the-clock-cycle coincidence the - ROMs probe. The APU snapshot layout is unchanged (the deferral scratch - fields are not serialized; `read_length` seeds `new_halt = halt`). - - ROM provenance (blargg, public domain) is in `tests/roms/LICENSES.md`; docs - updated in `docs/apu-2a03.md`, `docs/accuracy-ledger.md`, `docs/STATUS.md`, - `docs/testing-strategy.md`. +- **Mapper bank-reachability + IRQ regression net** — the tepples **Holy Mapperel** + cartridge-assembly ROMs wired into CI (`holy_mapperel.rs`, 17 zlib ROMs, + framebuffer-hash pinned with *settled* + *non-blank* guards): each detects its mapper + from bank/mirroring response, proves every PRG/CHR bank reachable, and exercises WRAM + + MMC3/FME-7 IRQ. 15/17 report `detail=0000`; the two MMC1 + two FME-7 ROMs surface a + documented WRAM-protection residual (closed later in v2.2.3), recorded in + `docs/accuracy-ledger.md`. +- **First PAL-region APU oracle** — blargg's `pal_apu_tests` (10 sub-ROMs) wired into CI + via a new on-screen-verdict runner, which also **corrects a false oracle** (the prior + `$6000`-status check passed vacuously on these PRG-RAM-less NROMs). Modeled the 2A07 PAL + frame-counter step positions (region-gated, NTSC tables untouched) and fixed the length + halt/reload write-ordering — **10/10 pass** (honestly 3/10 pre-model). NTSC byte-identity + preserved: `blargg_apu_2005` 11/11, AccuracyCoin 141/141. +- **MMC3 R1/R2 residual A12-phase study** (ADR 0002 F5.0) — a default-off observational + probe (`mmc3-a12-phase-probe`) that refines the F5.0 finding with fresh instrumentation: + the two `scanline_timing` residuals have zero post-access IRQ-clocking rises, but the two + "reload/set-IRQ-every-clock" residuals have 4 each, so "no post-access rise" is + ROM-specific, not structural. No default/scheduler change; all four residuals stay + `#[ignore]`'d; the ares-style M2-edge low-time filter remains the one untested axis-B + lever. ### Changed -- **fat-LTO release profile — measured, documented, and validated (v2.1.5 - build-optimization pass).** `[profile.release]` already shipped - `lto = "fat"` + `codegen-units = 1` (since the v1.0.0 engine transplant), but - the choice had never been backed by an in-repo A/B and `docs/performance.md` - even mis-stated the profile as `lto = "thin"` in two places. Ran the - measure-first A/B (`lto = "fat"` vs `lto = "thin"`, everything else held; same - host, back-to-back Criterion, `taskset`-pinned): fat is **+8.4%** - (`nes_run_frame_nestest`) / **+20.8%** (`nes_run_frame_flowing_palette` and - `ppu_tick_one_frame`) faster on every cross-crate path, and within noise - (+0.3%) on the single-crate `cpu_throughput` control — the signature of a - cross-crate-inlining win. Byte-identity was **verified, not assumed**: both - profiles rebuilt in release mode pass the golden oracle byte-for-byte - (AccuracyCoin 141/141, `nestest` golden-log 0-diff, `visual_regression`, - `apu_mixer`/volume audio). **No default-build change** — this documents and - retroactively justifies the existing fat-LTO default (well above the standing - > 3% + byte-identical bar) and corrects the stale profile text. Also documents - the opt-in `release-native` (`target-cpu=native`) and `x86-64-v3` host-tuned - build variants, and refreshes the `pgo.yml` determinism-oracle comments from - the stale `AccuracyCoin 139/139` to the shipped `141/141`. Detail: - `docs/performance.md` § "fat-LTO vs thin-LTO release-profile A/B". +- **fat-LTO release profile measured, documented, and validated** — the existing + `lto = "fat"` + `codegen-units = 1` default is now backed by an in-repo same-host A/B + (**+8.4%** to **+20.8%** on cross-crate paths, within noise on the single-crate control), + verified byte-identical (AccuracyCoin 141/141, nestest 0-diff). No default change; + corrects `docs/performance.md`'s stale "thin" text and the stale `139/139` PGO comments. ### Fixed -- **Netplay: the native TURN client now retransmits (RFC 5389 §7.2.1) — a real - production bug where symmetric-NAT relay fallback aborted on a single dropped - UDP datagram.** The native TURN client - (`crates/rustynes-netplay/src/relay.rs`) sent each `Allocate` / - `CreatePermission` request exactly once and waited for the reply; a single - dropped UDP datagram (request *or* response) hard-failed the whole NAT - traversal with `NatPhase::Failed("TURN allocate failed: …")` — so real - symmetric-NAT netplay over any lossy internet path was equally fragile, not - only the CI loopback test. On loopback this is rare but real — a loaded shared - CI runner (observed on `windows-latest`) can drop a `127.0.0.1` datagram (a - receive-buffer overflow, or — during the peer's socket-startup window — an ICMP - "Port Unreachable" that a subsequent `recv_from` surfaces as a transient - `ConnectionReset`), which intermittently red-lit - `nat_connect_loopback_relay_then_session_digests_agree` on `main` and, in turn, - blocked `release-auto`. The client now retransmits the request every 250 ms - (`RTO`) until the caller's overall timeout, guided by RFC 5389 §7.2.1 (a fixed - 250 ms RTO here, not the RFC default 500 ms + exponential backoff), recovering - transparently from a dropped datagram (STUN/TURN requests are idempotent, so a - duplicated request is answered again and any late duplicate response is - discarded by the transaction-id filter). This is a real robustness fix for - production symmetric-NAT fallback over lossy paths, not just a test workaround; - the session-digest-agreement assertion (the determinism contract) is unchanged. - The receive loop treats a read-timeout expiry (`WouldBlock` on Unix, `TimedOut` - on Windows) **and** a transient `ConnectionReset` / `ConnectionRefused` as - "retransmit and retry" rather than a hard failure. +- **Netplay: the native TURN client now retransmits (RFC 5389 §7.2.1)** — a real production + bug where symmetric-NAT relay fallback aborted on a single dropped UDP datagram + (`Allocate`/`CreatePermission` were sent once). It now retransmits every 250 ms until + timeout, recovering transparently (STUN/TURN requests are idempotent). This also fixed + the intermittent `nat_connect_loopback_relay` flake on `windows-latest` that had been + blocking `release-auto`. The determinism contract (session-digest agreement) is unchanged. ## [2.1.4] - 2026-07-11 - "Fathom" (accuracy hardening — opt-in OAM decay + BestEffort boot-smoke sweep + MMC3-clone A12/IRQ timing oracle; "Caliper") diff --git a/Cargo.lock b/Cargo.lock index 4cab633c..3fa79858 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "2.2.9" +version = "2.3.0" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "2.2.9" +version = "2.3.0" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "2.2.9" +version = "2.3.0" dependencies = [ "cc", "ureq", @@ -4329,7 +4329,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "2.2.9" +version = "2.3.0" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4346,7 +4346,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "2.2.9" +version = "2.3.0" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4357,7 +4357,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "2.2.9" +version = "2.3.0" dependencies = [ "anstyle", "arboard", @@ -4411,11 +4411,11 @@ dependencies = [ [[package]] name = "rustynes-gfx-shaders" -version = "2.2.9" +version = "2.3.0" [[package]] name = "rustynes-hdpack" -version = "2.2.9" +version = "2.3.0" dependencies = [ "lewton", "png", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "2.2.9" +version = "2.3.0" dependencies = [ "bytemuck", "cpal", @@ -4440,7 +4440,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "2.2.9" +version = "2.3.0" dependencies = [ "libc", "rust-libretro", @@ -4449,7 +4449,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "2.2.9" +version = "2.3.0" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4461,7 +4461,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "2.2.9" +version = "2.3.0" dependencies = [ "rustynes-core", "rustynes-hdpack", @@ -4476,7 +4476,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "2.2.9" +version = "2.3.0" dependencies = [ "futures-util", "js-sys", @@ -4492,7 +4492,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "2.2.9" +version = "2.3.0" dependencies = [ "bitflags 2.13.1", "criterion", @@ -4504,14 +4504,14 @@ dependencies = [ [[package]] name = "rustynes-ra" -version = "2.2.9" +version = "2.3.0" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "2.2.9" +version = "2.3.0" dependencies = [ "mlua", "piccolo", @@ -4522,7 +4522,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "2.2.9" +version = "2.3.0" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index 391cd3d5..d895755c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ members = [ default-members = ["crates/rustynes-libretro"] [workspace.package] -version = "2.2.9" +version = "2.3.0" edition = "2024" rust-version = "1.96" license = "GPL-3.0-or-later" diff --git a/NOTICE b/NOTICE index 79d6cc3a..68f90b49 100644 --- a/NOTICE +++ b/NOTICE @@ -58,10 +58,25 @@ distributable as GPL-3.0-or-later. Reference emulators consulted as behavioral oracles (no code derived) -------------------------------------------------------------------------------- -Separately from the derived code above, these were run only to observe and +Separately from the derived code above, these were used to observe and cross-check documented hardware behavior (no code incorporated): -* GeraNES -- GPL-3.0-only -- oracle / cross-check for several mapper boards. +* GeraNES -- GPL-3.0-only -- reference / cross-check for several mapper boards + and some frontend UI conventions. Its source was consulted (not purely + black-box) as a cross-reference to confirm publicly-documented behavior -- the + nesdev-wiki bus-conflict / address-decode masks and RetroArch's public + `#pragma parameter` shader-UI convention -- but no GeraNES code was copied: the + RustyNES implementations are independent Rust of that documented behavior. + The maintainers' ASSESSMENT is that only the unprotectable documented behavior + was relied on (not GeraNES's copyrightable expression), and therefore that no + GPL-3.0-only code is incorporated and the project's "or-later" grant is + unaffected. That is a position supported by a two-sided review, NOT a + certification: whether a given coincidence is the single correct expression of + a documented fact is a legal judgement, and this project does not self-certify + license cleanliness. Offered for human and expert review; a qualified reviewer + who disagrees should be treated as authoritative. See + docs/originality-and-provenance.md sec. 3 for the evidence and the residual + uncertainties. * higan -- GPL-3.0-or-later -- scheduler-structure reference / oracle. * ares -- BSD-2-Clause / Apache-2.0 -- palette-integration reference and oracle. @@ -89,18 +104,31 @@ Incorporated third-party components (permissively licensed, GPL-compatible) https://github.com/digital-sound-antiques/emu2413 Copyright (c) 2020 Mitsutaka Okazaki -- MIT -* TriCNES -- transistor-level NES emulator by the AccuracyCoin author; its +* TriCNES -- cycle-accurate C# NES emulator by the AccuracyCoin author, built + around a detailed sub-cycle CPU/PPU/APU/DMA state machine (the author + describes it as written "for playing back TASes" with "a focus on + test-driven accuracy"); its PPU address/data-multiplex (ALE / octal-latch), OAM-corruption, and per-cycle DMA-dispatch models are ported into RustyNES (crates/rustynes-ppu, rustynes-cpu, rustynes-core), and its full source is vendored as a golden oracle at crates/rustynes-test-harness/golden/tricnes/tricnes-full-src/. https://github.com/100thCoin/TriCNES (commit 9199870) Copyright (c) 2025 Chris Siebert -- MIT - Note (v2.2.6): the octal-latch / hybrid-address *timing* was calibrated to - TriCNES's per-dot behavior rather than derived independently, which reproduced - a TriCNES-specific artifact (mis-rendering mid-render $2006 writes, e.g. Rad - Racer). See docs/originality-and-provenance.md sec. 4 and ADR 0030; being - reworked to be documentation-derived in v2.3.0. TriCNES is MIT. + Note (v2.2.6 -> resolved v2.3.0): v2.2.6 disclosed that the octal-latch / + hybrid-address *timing* had been calibrated to TriCNES's per-dot behavior + rather than derived independently, and flagged a suspected TriCNES-specific + artifact (mid-render $2006 writes mis-rendering, e.g. Rad Racer) for a v2.3.0 + rework. v2.3.0 investigated it against the authoritative in-repo oracle: the + model passes the AccuracyCoin "Hybrid Addresses" / "ALE + Read" tests + (141/141 -- disabling the delayed-CopyV drops exactly that test to 140/141), + and Rad Racer's road/horizon renders cleanly. The suspected artifact does NOT + reproduce: it lived in the pre-v2.0.3 "+1 coarse-X" approximation, superseded + by the 2-cycle-ALE / delayed-CopyV model promoted in v2.0.3 (ADR 0030). The + current behavior is therefore documentation/oracle-derived -- it matches the + NESdev-documented delayed-`CopyV`-during-render timing and is pinned by the + MIT AccuracyCoin test ROM plus an exact-141/141 CI gate. TriCNES (MIT) remains + the original cross-reference; its source is vendored with attribution. See + docs/originality-and-provenance.md sec. 4 and ADR 0030. * rcheevos v12.3.0 -- RetroAchievements client runtime, vendored at crates/rustynes-cheevos/vendor/rcheevos/ (compiled only under the optional diff --git a/README.md b/README.md index 83adcf0c..c8441c2a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ </p> <p align="center"> - <a href="https://github.com/doublegate/RustyNES/actions"><img src="https://github.com/doublegate/RustyNES/workflows/CI/badge.svg" alt="Build Status"></a> <a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg" alt="License: GPL-3.0-or-later"></a> <a href="https://github.com/doublegate/RustyNES/releases"><img src="https://img.shields.io/badge/version-v2.2.9-blue.svg" alt="Version"></a> <a href="rust-toolchain.toml"><img src="https://img.shields.io/badge/rust-1.96-orange.svg" alt="Rust: 1.96"></a><br> + <a href="https://github.com/doublegate/RustyNES/actions"><img src="https://github.com/doublegate/RustyNES/workflows/CI/badge.svg" alt="Build Status"></a> <a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg" alt="License: GPL-3.0-or-later"></a> <a href="https://github.com/doublegate/RustyNES/releases"><img src="https://img.shields.io/badge/version-v2.3.0-blue.svg" alt="Version"></a> <a href="rust-toolchain.toml"><img src="https://img.shields.io/badge/rust-1.96-orange.svg" alt="Rust: 1.96"></a><br> <a href="#compatibility-and-accuracy"><img src="https://img.shields.io/badge/AccuracyCoin-100%25%20(141%2F141)-brightgreen.svg" alt="AccuracyCoin"></a> <a href="#compatibility-and-accuracy"><img src="https://img.shields.io/badge/nestest-0--diff-brightgreen.svg" alt="nestest"></a> <a href="https://doublegate.github.io/RustyNES/"><img src="https://img.shields.io/badge/play-in%20browser-success.svg" alt="Try in browser"></a><br> <a href="#platform-support"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Web%20%7C%20Android%20%7C%20iOS-lightgrey.svg" alt="Platform"></a> </p> @@ -81,39 +81,26 @@ platform for NES emulation. ## Highlights -| Feature | Description | -| ---------------------- | -------------------------------------------------------------------------------------------- | -| **Cycle-Accurate** | Master-clock-precise CPU / PPU / APU — AccuracyCoin 100% (141/141), nestest 0-diff | -| **172 Mapper Families** | NROM through MMC5, the full VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company ASIC, reusable-ASIC multicarts (FK23C / COOLBOY / MINDKIDS / Sachen / Waixing / Kaiser), and Vs.-System boards — classified Core / Curated / BestEffort behind a CI accuracy-honesty gate — plus a UNIF (`.unf`) cartridge loader | -| **Famicom Disk System**| `.fds` games with real-BIOS boot, writable disks, side-swapping, and 2C33 wavetable audio | -| **Vs. / PlayChoice-10**| Arcade ROMs in true 2C03 / 2C04 / 2C05 RGB with per-game DIP presets | -| **RetroAchievements** | Native `rcheevos` integration: achievements, leaderboards, rich presence, hardcore mode | -| **Rollback Netplay** | GGPO-style rollback for up to 4 players, over UDP or browser WebRTC | -| **TAS Tools** | Frame-perfect deterministic record / replay with save-state branching (`.rnm` format) | -| **Run-Ahead** | Latency reduction that hides a game's internal input lag | -| **Video Filters** *(v1.1.0)* | Full NES_NTSC composite / S-video, a CRT / scanline shader pass, and custom `.pal` palettes | -| **Lua Scripting** *(v1.1.0)* | Sandboxed Lua 5.4 — memory/state access, frame & access callbacks, HUD overlay (opt-in) | -| **ROM Library** *(v1.2.0)* | `.zip` loading + automatic `.ips`/`.ups`/`.bps` soft-patching + a per-game DB and in-app ROM-Database editor | -| **Shaders & HD Packs** *(v1.2.0)* | Live NTSC knobs, a composable shader stack + CRT preset bank, and a (default-off) Mesen-style HD-pack loader | -| **TAStudio Editor** *(v1.6.0)* | A piano-roll TAS editor — per-frame button grid with drag-paint, a save-state greenzone + lag log, markers, forkable branches, and `.rnmproj` projects | -| **Movie Interop** *(v1.6.0)* | FCEUX `.fm2` and BizHawk `.bk2` movie import / export to and from the native `.rnm` format, plus Lua movie driving (`emu.run` / `emu.frameadvance`) | -| **Mesen2-Class Debugger** *(v1.6.0)* | Expression / conditional breakpoints, R/W/X watchpoints, a watch window, conditional trace, a full hex editor (poke / freeze / heatmap / find), and RAM watch / search | -| **A/V Recording** *(v1.6.0)* | Synchronized video + audio capture to `.mp4` / `.mkv` via an `ffmpeg` pipe (default-off `av-record`, output-only) | -| **HD Audio** *(v1.6.0)* | HD-pack `<bgm>` / `<sfx>` OGG tracks triggered through the `$4100` register, mixed on top of the produced APU buffer (default-off `hd-pack`) | -| **Shader Ecosystem** *(v1.6.0)* | LMP88959 NTSC/PAL, hqNx / xBRZ upscalers, and a constrained `.slangp` / `.cgp` preset import on the composable ShaderStack | -| **Writable + Programmable** *(v1.7.0)* | Editing-capable debug tools (palette / nametable / CHR / OAM writeback, an iNES / NES 2.0 header editor, an inline 6502 assembler), a scriptable `tastudio.*` Lua API, host IPC automation (`script-ipc`), `.dbg` source maps, Zwinder tiered rewind, audio depth (stereo / reverb / 20-band EQ), web parity, and an i18n framework | -| **Android App** *(v1.8.x)* | A complete native Android app on the byte-identical core — a multi-touch + hardware-controller (P1–P4) UI, wgpu `SurfaceView` rendering, save-states, Lua, RetroAchievements, direct-IP / CGNAT-TURN netplay, and a box-art ROM library (GitHub-sideload now; free Google Play listing possible later) | -| **iOS / iPadOS App** *(v1.9.x)* | A complete native iOS app on the byte-identical core — a native SwiftUI shell over Metal (`wgpu`), multi-touch + GameController support, iCloud save-state sync, room-code rollback netplay, RetroAchievements, and the full TAStudio power-user suite (TestFlight now; free App Store listing possible later) | -| **Libretro Core** *(v1.10.0)* | A complete, cycle-accurate Libretro core (the `rustynes_libretro` shared library — `.so` / `.dylib` / `.dll` by platform) integrating RustyNES seamlessly into RetroArch with RetroAchievements, dynamic audio sync, and deterministic rollback/save-state support | -| **One-Clock Timebase** *(v2.0.0)* | A single canonical cycle counter with every CPU cycle a real bus access and a split-around-the-access PPU catch-up, replacing the five-counter dot-lockstep scheduler; the release's designated breaking-behavior change (ADR 0002 / ADR 0029) | -| **Vs. DualSystem** *(v2.0.0 core · v2.1.2 desktop)* | Core-level emulation of the two-CPU/two-PPU Vs. arcade cabinet boards (Tennis, Baseball, Wrecking Crew, Balloon Fight) via a shared-WRAM + cross-wired `$4016`/IRQ convergence model — now presented on desktop as a composed **two-screen** view (side-by-side / stacked) with both consoles cross-wired | -| **Audio Filter Model** *(v2.1.3)* | Pick the APU analog filter — `nes` (default, authentic front-loader), `famicom` (fuller low end), or `clean` (Mesen2-like) — a tonal-only fix for the "thin / missing bass channel" character; the default stays byte-identical | -| **Game Genie Database** *(v2.1.3)* | Per-game code nomination from a bundled catalog of **~10,800 codes across ~520 USA/World games**, header-robust CRC matching, and a Game Genie encoder — shipping on every target including the browser demo | -| **Generated NTSC Palette + Shaders** *(v2.1.2)* | An in-core, byte-identical-across-targets NTSC palette synthesizer (off by default) feeding a three-rung composite-shader ladder (simplified blur → LMP88959 → Bisqwit per-dot) with live emulator-synced dot-crawl | -| **NSF / NSFe, non-60 Hz** *(v2.1.2)* | The chiptune player parses the header play-speed divider and drives non-standard rates (PAL 50 Hz and custom) off a mapper cycle-timer IRQ, plus the chunked `NSFE` container | -| **OAM Decay** *(v2.1.4, opt-in)* | Mesen2-modeled dynamic-RAM decay of un-refreshed OAM rows with rendering disabled; off by default (byte-identical), deterministic when on, round-trips the save-state | -| **Documentation Handbook** *(v2.1.3)* | A Material-for-MkDocs site at [`/docs/`](https://doublegate.github.io/RustyNES/docs/) rendering the subsystem specs + user guide, alongside the playable demo (`/`) and rustdoc (`/api/`) on GitHub Pages | -| **Pure Rust** | `winit` + `wgpu` + `cpal` + `egui` frontend; safe `no_std + alloc` chip stack | +| Feature | Description | +| --- | --- | +| **Cycle-Accurate** | Master-clock-precise CPU / PPU / APU — AccuracyCoin 100% (141/141), nestest 0-diff | +| **One-Clock Timebase** | A single canonical cycle counter, every CPU cycle a real bus access, with a split-around-the-access PPU catch-up | +| **172 Mapper Families** | NROM through MMC5, the full VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company ASIC, reusable-ASIC multicarts (FK23C / COOLBOY / MINDKIDS / Sachen / Waixing / Kaiser), and Vs.-System boards — classified Core / Curated / BestEffort behind a CI accuracy-honesty gate — plus a UNIF (`.unf`) loader | +| **Famicom Disk System** | `.fds` games with real-BIOS boot, writable disks, side-swapping, a timed disk-head model, and 2C33 wavetable audio | +| **Vs. / PlayChoice-10** | Arcade ROMs in true 2C03 / 2C04 / 2C05 RGB with per-game DIP presets; Vs. DualSystem two-screen presentation on desktop | +| **RetroAchievements** | Native `rcheevos` integration: achievements, leaderboards, rich presence, hardcore mode | +| **Rollback Netplay** | GGPO-style rollback for up to 4 players over UDP or browser WebRTC — room-code / TURN traversal, matchmaking / lobby, and spectators | +| **TAStudio + Movie Interop** | A piano-roll TAS editor (drag-paint grid, save-state greenzone, lag log, markers, forkable branches) with `.fm2` / `.bk2` / `.fcm` / `.fmv` / `.vmv` import and the native `.rnm` format | +| **Run-Ahead & Rewind** | Input-lag-hiding run-ahead and a tiered (Zwinder) rewind window, on the deterministic snapshot path | +| **Mesen2-Class Debugger** | Expression / conditional breakpoints, R/W/X watchpoints, a hex editor, RAM search, a callstack, and `.dbg` source maps — editing-capable (palette / nametable / CHR / OAM writeback, header editor, inline 6502 assembler), read-only by default | +| **Lua Scripting** | Sandboxed Lua 5.4 — memory / state access, frame & access callbacks, a `tastudio.*` API, HUD overlay, and host-IPC automation (opt-in) | +| **Shaders & HD Packs** | An NES-NTSC composite / S-video filter, a composable CRT / scanline shader stack (CRT-Royale / guest-advanced / Megatron look), a generated NTSC palette, custom `.pal` palettes, and a Mesen-style HD-pack loader + builder (video + OGG audio) | +| **Cheats & Peripherals** | A ~10,800-code Game Genie database with per-game nomination + encoder, raw RAM cheats, and a broad peripheral set (Four Score, Zapper, Arkanoid, Power Pad, keyboards, mouse) | +| **A/V Recording** | Synchronized video + audio capture to `.mp4` / `.mkv` via an `ffmpeg` pipe (opt-in, output-only) | +| **NSF / NSFe Player** | Chiptune playback through the real APU + expansion synths, honoring non-60 Hz play-speed dividers | +| **Android & iOS Apps** | Complete native apps on the byte-identical core — touch + hardware controllers, save-states, netplay, RetroAchievements, and the shader stack (sideload / TestFlight; free store listing possible later) | +| **Libretro Core** | A cycle-accurate `rustynes_libretro` core for RetroArch (RetroAchievements, dynamic audio sync, deterministic rollback / save-state) | +| **Pure Rust** | `winit` + `wgpu` + `cpal` + `egui` frontend; safe `no_std + alloc` chip stack | <p align="center"> <img src="images/RustyNES_Arch-Blueprint_1.png" alt="RustyNES Architecture Blueprint" width="800"> @@ -171,217 +158,100 @@ gameplay frames spanning the bulk of the 172 mapper families. BestEffort behind a CI accuracy-honesty gate. A **UNIF (`.unf`) cartridge loader** resolves board names to the corresponding mapper. See [`docs/mappers.md`](docs/mappers.md). -- **Famicom Disk System** — `.fds` games with a user-supplied `disksys.rom` BIOS; the - disk drive and IRQs, writable disks (`.fds.sav`, `F9` side-swap), and 2C33 wavetable - audio. Real-BIOS boot works — Zelda, Metroid, and others boot into the game. v1.6.0's - **FDS-proper** pass adds a timed disk-head position model (a motor restart rewinds the - belt-driven disk and re-seeks across a deterministic not-ready window rather than - teleporting to track 0), `$4032` drive-status auto-insert, and a per-game CRC quirk - table — closing the Kid Icarus side-B post-registration replay. +- **Famicom Disk System** — `.fds` games with a user-supplied `disksys.rom` BIOS: the disk + drive and IRQs, writable disks (`.fds.sav`, `F9` side-swap), 2C33 wavetable audio, a timed + disk-head position / not-ready model, `$4032` drive-status auto-insert, and a per-game CRC + quirk table. Real-BIOS boot works — Zelda, Metroid, and others boot into the game. - **Vs. System / PlayChoice-10** — the 2C03 / 2C04 / 2C05 RGB PPUs with per-game DIP presets and exact palettes; real arcade ROMs render in true RGB. ### Modern features -- **RetroAchievements** *(opt-in `retroachievements` feature, native-only)* — login, - achievements, leaderboards, rich presence, and hardcore mode (which disables - save-state load / rewind / cheats), via the vendored MIT `rcheevos` library. +- **RetroAchievements** *(opt-in, native-only)* — login, achievements, leaderboards, rich + presence, and hardcore mode, via the vendored MIT `rcheevos` library. - **Rollback netplay** — GGPO-style rollback over UDP for up to 4 players (predict → - advance → roll back and re-simulate on the deterministic core), plus a **browser - (WebRTC) mesh** path with a deployable signaling / STUN bundle ([`deploy/`](deploy/)). -- **TAS movie recording and playback** — frame-perfect deterministic record / replay - with save-state branching, in a versioned `.rnm` format. -- **TAStudio piano-roll editor** *(v1.6.0)* — a Mesen2 / BizHawk-class TAS-authoring - surface: a per-frame button-grid with drag-paint editing, a save-state **greenzone** - for instant deterministic seeking, a **lag log**, named **markers**, **forkable - branches**, and `.rnmproj` project files. -- **Movie interop** *(v1.6.0)* — import and export FCEUX `.fm2` and BizHawk `.bk2` - movies to and from the native `.rnm` format; v1.7.0 widens import to `.fcm` / `.fmv` / - `.vmv` (and hashes the `.fm2` / `.bk2` exports), so RustyNES interoperates with the wider - TAS ecosystem. -- **Save state + rewind** — a 600-frame rewind ring, instant save / load, and a - snapshot fast path used by run-ahead, plus a thumbnail save-state manager. -- **Run-ahead** — hides a game's internal input lag for snappier controls, built on - the existing deterministic snapshot / restore. -- **Emulation-speed control** — 25 %–300 % speed presets (slow-motion to fast), - hold-to-fast-forward, and single-frame advance while paused. -- **Display-sync pacing + lock-free audio** — an `auto` / `display` / `vrr` / - `wallclock` pacing matrix ends display-beat judder; a lock-free SPSC audio ring with - dynamic rate control keeps audio clean and underrun-free; master volume, - per-APU-channel mutes, and a graphic equalizer (selectable **5-band** or - **20-band ISO third-octave**) round out the audio mixer. -- **Video filters** *(v1.1.0)* — a full **NES_NTSC composite / S-video** filter, a - **CRT / scanline** shader pass (curvature, scanlines, aperture mask), and - **custom `.pal` palette** loading, layered on the existing 8:7 pixel-aspect + overscan - pipeline. -- **NSF / NSFe music player** *(v1.1.0; extended v2.1.2)* — drop in a `.nsf` chiptune and - play it through the real APU, with a track selector and the file's title / artist / - copyright. v2.1.2 parses the header **play-speed divider** and drives non-standard - rates correctly (PAL 50 Hz and custom dividers on the NTSC console, via a mapper - cycle-timer IRQ), and parses the chunked **`NSFE`** container. -- **Lua scripting** *(v1.1.0, opt-in `scripting` feature, native-only)* — a sandboxed - **Lua 5.4** engine: read / write memory, inspect CPU state, react to per-frame / - per-access events, draw an HUD overlay, and drive control actions. v1.6.0 adds - **movie driving** (`emu.run` / `emu.frameadvance` to step the emulator from a script) - and **data breadth** (named memory domains, sized reads, and a `joypad` table). v1.7.0 - adds a `tastudio.*` API to drive the piano-roll editor from a script, full Lua parity - (`getScreenBuffer` / `setState` / value-modifying callbacks), and a host-mediated IPC - sandbox (`comm.*` / `client.*` / `userdata.*`, opt-in `script-ipc`). The browser build - runs an experimental `piccolo` Lua backend (observational, not byte-parity with native - `mlua`, ADR 0012). See [`docs/scripting.md`](docs/scripting.md). -- **Cheats and input devices** — Game Genie codes (with a Game Genie *encoder*) and raw - RAM cheats. *(v2.1.3)* The Cheats panel now **nominates** the known Game Genie codes - for the loaded game from a bundled catalog of **~10,800 codes across ~520 USA/World - games** (ingested from the openly-licensed libretro Game Genie database), matched - header-robustly on both the header-excluded and full-file CRC32 so a re-headered dump - still resolves — shipping on every target including the browser demo. Plus a broad - peripheral set: the standard pad, **Four Score** (4-player), - the **Arkanoid Vaus** paddle (both ports), the **Zapper** light gun, the **Power Pad**, - the **SNES mouse**, the **Family BASIC keyboard**, the **Family Trainer** mat, the - Konami / Bandai **Hyper Shot**, and the **Subor keyboard**. **Turbo / autofire** with an - **input-display overlay** (the consolidated all-device Input Display), a **per-game - database** of nametable-mirroring overrides, and USB gamepads (`gilrs`) with a deadzone - control and hot-plug detection. -- **Desktop UX** — a native menu bar, recent-ROMs list, a tabbed Settings window, - light / dark / system themes, 8:7 pixel-aspect correction, optional overscan - cropping, integer window-size presets, a pause-dim overlay, a status bar, - screenshot-to-file/clipboard, and drag-and-drop ROM loading. -- **egui debugger + devtools** — a read-only CPU / PPU / APU / memory / OAM / mapper - inspector by default, plus opt-in **breakpoints / watchpoints**, a **cycle trace - logger**, and an **event viewer** (IRQ / NMI / register-write timeline) behind the - `debug-hooks` feature — all preserving the strict determinism contract when off. -- **Mesen2-class debugger depth** *(v1.6.0, `debug-hooks`)* — **expression / - conditional breakpoints**, **read / write / execute watchpoints**, a **watch window**, - **conditional trace** logging, a full **hex editor** (poke, freeze, write-heatmap, - find), and **RAM watch / search** — the debugging surface a TAS or homebrew developer - expects, all read-only-by-default and determinism-preserving. -- **A/V recording** *(v1.6.0, opt-in `av-record` feature, native-only)* — capture the - running game to an `.mp4` / `.mkv` via an external `ffmpeg` pipe (H.264 + AAC). It is - a read-only tap on the already-produced framebuffer and audio, so it never touches the - emulator or the determinism contract, and the default build pulls no extra Rust - dependencies (only the system `ffmpeg` at run time). -- **HD-pack HD audio** *(v1.6.0, opt-in `hd-pack` feature, native-only)* — HD-pack - `<bgm>` / `<sfx>` OGG tracks triggered through the `$4100` control register and mixed - (pure-Rust `lewton` decoder) on top of the buffer the core already produced — the audio - analogue of HD tile substitution, output-only and determinism-neutral. -- **Shader / filter ecosystem** *(v1.6.0)* — built-in **LMP88959** composite NTSC/PAL, - **hqNx** and **xBRZ** edge-directed pixel-art upscalers, and a constrained RetroArch - **`.slangp` / `.cgp`** preset importer (mapping well-known shader stems onto the - built-in passes, never silently dropping the unsupported ones) — all composable on the - off-by-default ShaderStack, post-framebuffer and never touching the core. See - [`docs/frontend.md`](docs/frontend.md) and ADR 0013. - -### Authoring and automation *(v1.7.0)* - -v1.7.0 "Forge" is the release where the tools become **writable** and **programmable** — -every item below is additive and off-by-default, so the shipped core stays byte-identical -and AccuracyCoin holds 100% (139/139). - -- **Editing-capable debug tools** *(`debug-hooks`)* — the inspector panels become editors: - **palette / nametable / CHR / OAM writeback** (gated like `emu.write`), an - **iNES / NES 2.0 header editor**, and an **inline 6502 assembler** that patches code live. -- **Deeper debugger** *(`debug-hooks`)* — on top of the Mesen2-class breakpoint / - watchpoint / hex-editor / RAM-search surface, a **CallstackManager** with step-into / - over / out modes, a **memory-access counter** with uninitialized-read detection, and - **ca65 / cc65 `.dbg` source maps** (plus the existing `.sym` / `.mlb` / `.nl` symbol - files) for source-level debugging. -- **Scriptable TAStudio** *(`scripting`)* — a `tastudio.*` Lua API drives the piano-roll - editor from a script, with analysis-canvas callbacks, alongside full Lua parity - (`getScreenBuffer`, `setState`, value-modifying callbacks). -- **Host IPC / automation** *(opt-in `script-ipc` feature, native-only)* — a - host-mediated `comm.*` / `client.*` / `userdata.*` sandbox lets an external process - drive and observe the emulator over IPC for automation and CI harnesses, behind a - documented security posture (ADR 0016). -- **Rewind, deepened** — a **HistoryViewer**, an **Export-Last-30-seconds** to `.rnm`, and - a **Zwinder**-style tiered greenzone (XOR-delta + LZ4) that stretches the rewind window - far beyond the classic ring without bloating memory. -- **Expansion-audio NSF router** — the NSF / NSFe player now routes through the real - VRC6 / VRC7 / FDS / MMC5 / Namco 163 / Sunsoft 5B expansion-audio synths, and MMC5's - expansion audio is synthesized in-game. -- **Movie import breadth** — in addition to FCEUX `.fm2` and BizHawk `.bk2`, RustyNES now - imports `.fcm` / `.fmv` / `.vmv` movies (and hashes `.fm2` / `.bk2` exports), widening - TAS-ecosystem interop. -- **HD-Pack Builder** *(`hd-pack`)* — author Mesen-format HD packs from the running game - (ADR 0017); the loader was also corrected to parse the authentic Mesen `<tile>` format - (ADR 0018). -- **Audio depth** — bypass-by-default **stereo panning** (per-APU-channel pan), a Schroeder - **reverb** + headphone **crossfeed**, an **output-device picker**, the **20-band** EQ - mode, and per-context (game / menu) volume (ADR 0020). -- **Per-game config overlay** — a `<rom>.json` overlay (region / mapper / submapper / - mirroring overrides), a **DIP-switch editor**, and a **lag-frame counter** (ADR 0019). -- **Internationalization (i18n)** — a compile-time string catalog with a Settings language - picker; English is the default and universal fallback (byte-identical strings), with - Spanish shipped to prove the mechanism (ADR 0023). -- **Spectator netplay** — observers can join a rollback session read-only, alongside the - existing 2–4-player rollback. - -### Web / WebAssembly *(v1.7.0 reach wave)* - -The browser build closes several desktop-parity gaps with web-specific implementations -(these live only in the wasm build, so the native build is byte-identical): - -- **Lua in the browser** — the experimental `piccolo` Lua backend runs end-to-end from a - `.lua` picker / paste box (observational, off by default, never in the determinism - oracle — ADR 0012). -- **File System Access API** — TAS `.rnm` exports save through a native "Save As" dialog on - Chromium browsers, with a graceful download fallback on Firefox / Safari (ADR 0021). -- **Gamepad API** — `navigator.getGamepads()` is polled each frame and routed to player 1 - at the same late-latch as touch / keyboard, so it records and replays identically. -- **PWA / offline** — a web manifest + service worker make the demo installable and - offline-capable, within a 5 MiB bundle budget. -- **`?settings=` share-links** — a curated subset of `Config` (filter + knobs, overscan, - theme, aspect, zoom, FPS, volume) round-trips through a compact URL-safe blob, with a - "Copy share link" button (ADR 0022). - -### Display, audio & accuracy — the "Fathom" line *(v2.1.x)* - -The v2.1.x "Fathom" releases deepen display fidelity, audio, and accuracy on top of -the v2.0.0 core. Everything here is additive and default-off (or tonal-only on the -default), so the shipped build stays **byte-identical** and AccuracyCoin holds -**141/141** — v2.1.0 landed the accuracy-remediation work (a display-only PPU -palette-backdrop-override fix, 86 mapper families promoted BestEffort → Curated, and -the MMC3 R1/R2 scanline-IRQ residual closed by design), and v2.1.2–v2.1.4 build on it: - -- **APU audio filter model** *(v2.1.3)* — the authentic NES front-loader filter (a 90 Hz - plus an aggressive 440 Hz high-pass plus a 14 kHz low-pass) is byte-correct but rolls off - the bass hard, reading as a "thin / missing channel". **Settings → Audio → Filter model** lets you - pick **`nes`** (default, authentic — byte-identical to earlier builds), **`famicom`** (a - single ~37 Hz high-pass, fuller low end), or **`clean`** (a ~10 Hz DC-block, the - Mesen2-like character). Tonal only — channel content, determinism, and the audio oracle - are unchanged on the default. -- **Generated NTSC palette** *(v2.1.2)* — an in-core synthesizer (`generate_base_palette`) - produces the 64-entry base palette from a model of the 2C02's composite output (Bisqwit / - ares YIQ integration), tunable via saturation / hue / contrast / brightness / gamma. - Every transcendental routes through `libm`, so the output is byte-identical across all - targets (x86 / aarch64 / wasm / `thumbv7em`) and locked by a committed golden. Off by - default; enable under Settings → Palette → "Generated NTSC". -- **Composite-shader ladder** *(v2.1.2)* — a three-rung display-only ladder (simplified blur - → **LMP88959** composite → **Bisqwit** per-dot), with live emulator-synced dot-crawl now - wired to LMP88959 as well as Bisqwit. All passes are display-only — they run entirely in - the frontend, so the `visual_regression` corpus (which hashes the *pre-shader* core - framebuffer, `Nes::framebuffer()`) is byte-identical with any filter active. -- **Vs. `DualSystem` second-screen presentation** *(v2.1.2, desktop)* — a loaded Vs. - `DualSystem` cabinet (Balloon Fight, Wrecking Crew, Tennis, Baseball) now runs **both** - cross-wired consoles and presents them together, side-by-side (512×240, default) or - stacked (256×480). P1/P2 drive the main console, P3/P4 the sub. The single-console path - stays byte-identical (ADR 0032); run-ahead / rewind / netplay / TAS are scoped out of dual - mode. -- **NSF non-60 Hz + NSFe** *(v2.1.2)* — the chiptune player now honors the header play-speed - divider (driving sub-60 Hz rates off a mapper cycle-timer IRQ) and parses the chunked - `NSFE` container; the standard 60 Hz path is byte-identical. -- **Optional OAM decay** *(v2.1.4, opt-in)* — the 2C02's OAM is dynamic RAM; with rendering - disabled long enough, un-refreshed rows decay to a fixed garbage pattern. RustyNES now - models this exactly like Mesen2 (a 3000-CPU-cycle refresh window per 8-byte row). It is - **off by default** (byte-identical output and suites), deterministic when on (driven off - the PPU's monotonic dot counter), and round-trips the save-state via an additive - `PPU_SNAPSHOT_VERSION` v7 tail. Enable via **Settings → Emulation → "OAM decay - (accuracy)"**. -- **Mapper regression + IRQ oracles** *(v2.1.4)* — a CI boot-smoke sweep of all 26 - `BestEffort` mapper families (auto-derived from the tier classifier) and a shared - MMC3-clone A12/IRQ timing oracle (eleven clone boards driven bit-for-bit against a - reference `Mmc3`) add safety-net coverage without moving any tier or touching the core. - -### Android *(v1.8.x)* + advance → roll back on the deterministic core), plus a browser **WebRTC** mesh with a + deployable signaling / STUN bundle ([`deploy/`](deploy/)), room-code / TURN traversal, + matchmaking / lobby, and read-only spectators. +- **TAS + TAStudio** — frame-perfect deterministic record / replay in the versioned `.rnm` + format, plus a Mesen2 / BizHawk-class piano-roll editor: a drag-paint button grid, a + save-state **greenzone** for instant seeking, a lag log, markers, forkable branches, and + `.rnmproj` projects. Imports FCEUX `.fm2` / BizHawk `.bk2` / `.fcm` / `.fmv` / `.vmv`. +- **Save state, rewind, run-ahead** — instant save / load, a thumbnail manager, a tiered + (Zwinder) rewind window, and input-lag-hiding run-ahead — all on the deterministic + snapshot path. +- **Speed, pacing, audio** — 25 %–300 % speed presets, hold-to-fast-forward, frame advance; + an `auto` / `display` / `vrr` / `wallclock` display-sync matrix; and a lock-free audio + ring with dynamic rate control, per-channel mutes, and a 5- / 20-band equalizer. +- **Lua scripting** *(opt-in, native-only)* — a sandboxed **Lua 5.4** engine: read / write + memory, inspect state, react to per-frame / per-access events, draw an HUD, and drive + movies (`emu.run` / `emu.frameadvance`) and the piano-roll (`tastudio.*`), with a + host-mediated IPC sandbox. The browser build runs an experimental `piccolo` backend + (observational, never in the determinism oracle). See [`docs/scripting.md`](docs/scripting.md). +- **Cheats + peripherals** — a Game Genie encoder plus a bundled ~10,800-code database with + per-game nomination (header-robust CRC matching), raw RAM cheats, and a broad peripheral + set (standard pad, Four Score, Arkanoid Vaus, Zapper, Power Pad, SNES mouse, Family BASIC + and Subor keyboards, Family Trainer, Hyper Shot). Turbo / autofire, an all-device + input-display overlay, and USB gamepads (`gilrs`) with deadzone + hot-plug. +- **Debugger + devtools** *(opt-in `debug-hooks`)* — a read-only CPU / PPU / APU / memory / + OAM / mapper inspector by default; opt-in expression / conditional breakpoints, R/W/X + watchpoints, a watch window, conditional + cycle trace, an event viewer, a full hex editor + (poke / freeze / heatmap / find), RAM search, and a callstack with step in / over / out — + all determinism-preserving when off. +- **A/V recording** *(opt-in `av-record`, native-only)* — capture to `.mp4` / `.mkv` via an + external `ffmpeg` pipe; a read-only tap on the produced framebuffer / audio, so it never + touches the core. + +### Authoring and automation *(opt-in `debug-hooks` / `scripting` / `script-ipc`)* + +- **Editing-capable debug tools** — the inspectors become editors: palette / nametable / + CHR / OAM writeback, an iNES / NES 2.0 header editor, and an inline 6502 assembler; plus + `ca65` / `cc65` `.dbg` source maps (and `.sym` / `.mlb` / `.nl`) for source-level debugging. +- **Host IPC / automation** — a host-mediated `comm.*` / `client.*` / `userdata.*` sandbox + lets an external process drive and observe the emulator over IPC for CI harnesses, behind + a documented security posture. +- **HD packs** — an HD-Pack Builder authors Mesen-format packs from the running game, and + the loader mixes HD-pack `<bgm>` / `<sfx>` OGG audio through `$4100`. +- **Audio depth** — stereo panning, Schroeder reverb + crossfeed, an output-device picker, + and per-context (game / menu) volume. +- **Per-game config + i18n** — a `<rom>.json` overlay (region / mapper / mirroring + overrides), a DIP-switch editor, a lag-frame counter, and a compile-time i18n catalog + (English default + universal fallback; Spanish shipped). + +### Display and audio + +- **Video filters + shaders** — a full NES-NTSC composite / S-video filter and a composable + CRT / scanline shader stack (curvature, scanlines, aperture mask; LMP88959 composite, + hqNx / xBRZ upscalers, and a constrained RetroArch `.slangp` / `.cgp` importer), plus a + three-rung composite-shader ladder (blur → LMP88959 → Bisqwit per-dot) with live + emulator-synced dot-crawl and custom `.pal` palettes — all display-only and off by + default, so the pre-shader framebuffer stays byte-identical. See [`docs/frontend.md`](docs/frontend.md). +- **Generated NTSC palette** *(opt-in)* — an in-core synthesizer builds the 64-entry palette + from a 2C02 composite model (tunable saturation / hue / contrast / brightness / gamma), + byte-identical across all targets via `libm` and locked by a committed golden. +- **APU filter model** — pick the analog filter: `nes` (default, authentic front-loader), + `famicom` (fuller low end), or `clean` (Mesen2-like) — tonal-only, byte-identical on the + default. +- **NSF / NSFe player** — chiptune playback through the real APU and expansion synths, with a + track selector and metadata, honoring non-60 Hz play-speed dividers and the chunked `NSFE` + container. +- **OAM decay** *(opt-in)* — Mesen2-modeled dynamic-RAM decay of un-refreshed OAM rows; off + by default (byte-identical), deterministic when on, and round-trips the save-state. + +### Web / WebAssembly + +The browser build runs the same core with web-specific glue (native builds are byte-identical): + +- **Lua in the browser** — the experimental `piccolo` backend runs from a `.lua` picker / + paste box (observational, off by default, never in the determinism oracle). +- **File System Access API** — TAS `.rnm` exports use a native "Save As" on Chromium, with a + download fallback on Firefox / Safari. +- **Gamepad API** — `navigator.getGamepads()` is polled each frame at the same late-latch as + touch / keyboard, so it records and replays identically. +- **PWA + share-links** — an installable, offline-capable manifest + service worker (within a + 5 MiB budget), plus `?settings=` URL share-links for a curated `Config` subset. + +### Android RustyNES runs as a complete native **Android app** on the byte-identical core (so AccuracyCoin holds 141/141 as on desktop), built on a shared **`rustynes-mobile`** @@ -409,7 +279,7 @@ no fixed version (see [Roadmap](#roadmap)). RustyNES is permanently open-source income-free (ADR 0035): no ads, no tracking, no paid unlock. Details in [`docs/android.md`](docs/android.md). -### iOS / iPadOS *(v1.9.x)* +### iOS / iPadOS RustyNES runs as a native **iOS / iPadOS app** on the byte-identical core (maintaining the same 141/141 AccuracyCoin bar as desktop), built on the shared **`rustynes-mobile`** UniFFI bridge and a native SwiftUI shell: @@ -536,9 +406,11 @@ Everything has a keyboard shortcut, but nothing requires one. Slot picker, a thumbnail **Save States…** manager, Take Screenshot, Copy Screenshot to Clipboard), Emulation (Pause, Reset, Power Cycle, **Speed 25–300 %**, Run-Ahead 0–3, the region label, Vs. Insert Coin / FDS Swap Disk Side when relevant), Tools - (Cheats, TAS Movies, the **TAStudio** piano-roll editor, **Record A/V**, Netplay, - RetroAchievements, Performance Monitor — opened as floating windows without the - debugger), View (Settings, Theme, 8:7 Pixel Aspect, + (Cheats, TAS Movies, the **TAStudio** piano-roll editor, the **Audio Mixer**, **Record + A/V**, Netplay, RetroAchievements, a read-only **ROM Info** browser, and the **Performance + Monitor** — opened as floating panels; on native, every tool panel also offers a **Detach** + button that pops it out into a real, separate OS window you can move to another monitor), + View (Settings, Theme, 8:7 Pixel Aspect, Hide Overscan, Fullscreen, Window Size 1x–4x, Show FPS, Pause When Unfocused, Show Menu Bar), Debug (the debugger overlay + per-chip panels), and Help (Keyboard Shortcuts, About). @@ -760,7 +632,7 @@ The reproducible record (methodology, all benches, and the historical A/B) is in | [Scheduler](docs/scheduler.md) | The master-clock lockstep model | | [CHANGELOG.md](CHANGELOG.md) | Version history and release notes | | [Documentation handbook](https://doublegate.github.io/RustyNES/docs/) | The Material-for-MkDocs site rendering the subsystem specs + user guide (also on GitHub Pages) | -| [Roadmap](to-dos/ROADMAP.md) | The forward roadmap — deepening the project through v2.2.0 and the v2.3.0 store launch | +| [Roadmap](to-dos/ROADMAP.md) | The forward roadmap — the v2.2.6 → v2.3.0 de-monetization + NESdev-remediation line and beyond | | [Release plans](to-dos/plans/README.md) | Per-release design plans (v1.0.0 → the v2.0.0 "Timebase" set and the v2.1.x "Fathom" line) + the reference-emulator research dives that fed them | | [iOS / iPadOS App](docs/ios.md) | Native SwiftUI shell over Metal (wgpu) — v1.9.x TestFlight | | [Libretro Core](docs/libretro/WALKTHROUGH.md) | Libretro core architecture, snapshot determinism, and RetroArch setup | @@ -776,12 +648,13 @@ The reproducible record (methodology, all benches, and the historical A/B) is in | Testing | [docs/testing-strategy.md](docs/testing-strategy.md) | | Netplay | [docs/netplay-webrtc.md](docs/netplay-webrtc.md) | -Architecture Decision Records live in [`docs/adr/`](docs/adr/) (0001–0032, including +Architecture Decision Records live in [`docs/adr/`](docs/adr/) (0001–0036, including 0028–0029 the v2.0.0 "Timebase" one-clock timebase + save-state/movie-format break, 0030 the AccuracyCoin 2-cycle-ALE / octal-latch closure, 0031 the game-database -must-not-override-mapper-controlled-state gate, and 0032 the Vs. `DualSystem` desktop -presentation). (The deeper engine-development audit logs are kept locally, outside the -public repo.) +must-not-override-mapper-controlled-state gate, 0032 the Vs. `DualSystem` desktop +presentation, 0035 RustyNES is permanently non-commercial, and 0036 the relicense to +GPL-3.0-or-later as a derivative work). (The deeper engine-development audit logs are +kept locally, outside the public repo.) The hosted GitHub Pages deployment serves **three** sections from one artifact: the playable WebAssembly demo at @@ -795,133 +668,27 @@ and the Material-for-MkDocs documentation handbook at ## Current Release -RustyNES's current release is **v2.2.9 "Studio II"**, a **frontend quality-of-life** -release (4th of the v2.2.6 → v2.3.0 NESdev-remediation line): TAStudio piano-roll edits -now drive the emulator, `.bk2` movies play back honoring their `LogKey` column order, and -tool windows can **detach into real OS windows** (fixing the Windows-10 trapped-window -report). Frontend-only, so the deterministic core is untouched (**AccuracyCoin 141/141**, -nestest 0-diff); the multi-window behavior awaits an on-device check. It builds on -**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 -byte-identical (**AccuracyCoin 141/141**, nestest 0-diff); the base BLEP is a verified -81.6 dB-SFDR band-limited decimator. It builds on **v2.2.6 "Almanac"**, the -**de-monetization and provenance** release that opened the **v2.2.6 → v2.3.0** line -addressing NESdev-forum feedback. **v2.2.6** carried **zero emulation-core behavior changes** — so **AccuracyCoin holds -141/141 (100.00%)**, nestest is 0-diff, and the `#![no_std]` chip stack, save-state / TAS -/ netplay formats, and every golden vector are byte-identical by construction. What it -does: - -- **RustyNES is now permanently open-source and income-free (ADR 0035).** All planned - monetization is removed — the `rustynes-monetization` crate, the `docs/monetization/` - design set, and the Android/iOS billing, ad, freemium/demo, and paywall layers. The - native apps are **kept as free FOSS apps**: no ads, no tracking, no paid unlock, every - feature available. The free Google-Play *services* (Play Games achievements, Cast, - Integrity, in-app update, cloud save) and the `foss`/`play` split are retained. -- **Provenance accuracy.** `NOTICE` and - [`docs/originality-and-provenance.md`](docs/originality-and-provenance.md) now disclose - honestly that the PPU octal-latch / hybrid-address *timing* was calibrated to TriCNES's - per-dot behavior (beyond black-box oracle use), which reproduces a TriCNES-specific - artifact that mis-renders mid-render `$2006` writes (e.g. Rad Racer) — flagged for a - documentation-derived rework in v2.3.0 (ADR 0030). - -The prior release, **v2.2.5 "Colophon"** (a **provenance, licensing, and -documentation-integrity** release, also zero-core-change), corrected how the project -*describes its own provenance*, prompted by community review: - -- A full-tree audit reworded in-source comments that had mischaracterized - implementations of publicly-documented hardware behavior as "ports of" copyleft - emulators (Mesen2, puNES). Those behaviors are implemented from the NESdev wiki, - datasheets, and the documented 6502 behavior, and cross-checked against - reference emulators as *oracles* — the comments now say exactly that. -- `NOTICE` was rewritten to disclose that GPL-licensed emulators (Mesen2/MesenCE, - higan, GeraNES, ares, FCEUX, Nestopia UE, puNES) were used only as behavioral - oracles (no code incorporated); to attribute the genuinely incorporated - permissive components (emu2413, TriCNES, rcheevos — all MIT), the bundled fonts, - and the bundled test ROMs; and to credit the CRT-shader / NTSC-filter *visual - influences* (CRT-Royale, crt-guest-advanced, Sony Megatron, EMMIR's NTSC-CRT, - Bisqwit) as independent reimplementations. -- The CRT shaders and NTSC filters were reviewed at source level and reworded from - "port / condensation of X" to independent single-pass reimplementations of the - *look and technique* — copyright protects code expression, not a visual look, and - no upstream shader source is incorporated. -- A new [`docs/originality-and-provenance.md`](docs/originality-and-provenance.md) - gives an honest account of where RustyNES advances, diverges from, or - independently re-derives NES emulation technique, and its license posture — - including that the project is heavily AI-assisted. -- The README (this file), `tests/roms/LICENSES.md`, and related docs were corrected - for accuracy: an AI-assistance disclosure was added, a comparison graphic with - inaccurate details was removed, and a mislabeled screenshot caption was fixed. - -It follows **v2.2.4 "Cartridge"**, a **libretro / RetroArch distribution** cut so -RetroArch users can pull the core from the in-app downloader via the Libretro -buildbot ([git.libretro.com/libretro/RustyNES](https://git.libretro.com/libretro/RustyNES)); -its concrete work was a `rustynes_libretro.info` metadata correction -(**`disk_control` `false` → `true`**, exposing multi-disk FDS swapping in -RetroArch's Quick Menu; `display_version` `v1.0.0` → `v2.2.4`; mapper count -`168` → `172`), with zero emulation-core changes. - -Earlier, **v2.2.3 "Datum"**, a performance and accuracy-closure patch: the -specialized PPU fast dot path (**−11.3%** on rendering-heavy content, -differential-tested bit-identical since v2.1.8) promoted to the **default** and -exposed to users for the first time; PGO-optimized Linux release binaries when -the >3%-and-byte-identical gate passes; a same-runner relative frame-time -regression gate; the **last two Holy Mapperel residuals closed** (all 17 ROMs -`detail=0000` — MMC1's two WRAM write-protect layers validated against **60/60** -commercial ROMs, FME-7's open-bus window); the Sunsoft 5B level calibrated to -Mesen2 (widening `Mapper::mix_audio` to `i32`); and a save-state schema gap fixed -(`PPU_SNAPSHOT_VERSION` 8 + APU v4 tail). Two optimizations were measured and -rejected, documented with their numbers. - -Earlier still: **v2.2.2 "Conduit"**, a build, distribution, and CI-integrity patch -that took the libretro buildbot recipe from 1 of 10 jobs green to **all ten -building**, hardened the GitHub Actions supply chain, and collapsed the -toolchain to a single pinned source of truth with no `nightly` on any build -path; and **v2.2.1**, a **housekeeping patch** on top of -v2.2.0 "Capstone" (below): archives two batches of dev/research tooling (the -Game Genie header-robust re-key's research scripts and a 2A03-revision -DMA-divergence probe), consolidates six open Dependabot PRs with **zero -source changes** (pollster, wide, tungstenite/tokio-tungstenite, bytemuck, -cc, actions/setup-python), and wires four gitignored, BIOS-gated smoke tests -against `TakuikaNinja`'s FDS hardware-verification probes (regression -insurance for behavior RustyNES already models correctly). **Zero accuracy, -feature, or core changes** — AccuracyCoin holds **141/141 (100.00%)**, -unchanged from v2.2.0. - -It follows **v2.2.0 "Capstone"**, the milestone cut that **closes the -v2.1.5 → v2.2.0 "deepen the existing project" run**, landing its two remaining marquees — the -**netplay matchmaking/lobby** stack (a browse-and-join room directory + server-side quick-play -over the existing room-code / TURN transport, delayed-stream spectators, a graded -hysteresis-based desync verdict, and multi-second peer-liveness RTT timeouts — all signaling -and telemetry only) and the **FDS medium model** (per-block **CRC-16/KERMIT** re-emitted on -every BIOS write + a synthesized gap/mark wire image, an opt-in continuous analog head-seek / -velocity model, and a BIOS-free synthetic write-verify oracle) — alongside a **peripherals** -pass (the Famicom `$4016`-bit-2 microphone and a 3×3-aperture Zapper light-timing model, both -default-off) and a **quality/security** pass (cargo-fuzz targets grown 3 → 8, which surfaced and -fixed two real OOM-DoS paths in the `.rnm` movie deserializer; a read-only ROM Info browser; -and four new MkDocs handbook pages). Every change is additive or default-off and the -deterministic core is untouched, so **AccuracyCoin holds 141/141 (100.00%)**, `visual_regression` -is byte-identical, `pal_apu_tests` is 10/10, and save-state / TAS / netplay replay stay -bit-identical. It follows **v2.1.10 "Fathom" ("Loom")**, the creator-tools-and-web-parity step -(TAStudio greenzone + Lua API breadth + browser RetroAchievements + Vs. `DualSystem` libretro -presentation). - -The v2.1.x line opened with **v2.1.0**, the accuracy-remediation release (a display-only -PPU palette-backdrop-override fix, 86 mapper families promoted BestEffort → Curated, and -the MMC3 R1/R2 scanline-IRQ residual closed by design), then **v2.1.1** (the Wizards & -Warriors game-database mirroring-override freeze fixed at the root), **v2.1.2 "Prism"** -(a generated NTSC palette + composite-shader ladder, Vs. `DualSystem` desktop -second-screen presentation, and NSF non-60 Hz / NSFe), and **v2.1.3 "Codex"** (the APU -audio filter-model selector, the full Game Genie code database + per-game nomination, and -the Material-for-MkDocs documentation handbook). The whole line rides on the **v2.0.0 -"Timebase"** one-clock scheduler rewrite, and the same byte-identical cycle-accurate core -powers the desktop, browser, Android, iOS, and Libretro builds. +RustyNES's current release is **v2.3.0 "Datum II"** — the capstone that closes the +v2.2.6 → v2.3.0 NESdev-remediation line. Tool panels now open as **real OS windows** +(the v2.2.9 affordance only *embedded* them, so the Windows-10 "trapped window" report +is now genuinely resolved), and every tool window is detachable. Profiling that work +uncovered a **frame-pacing defect** that had been degrading any session with a debugger +panel open: the render path held the emulator lock across the blocking swapchain wait +and present, stalling frame production — now split so the lock covers only the UI build. +The PPU's per-dot helpers also got **−5.13% / −3.51%** frame cost, byte-identically. +Both remaining forum-reported accuracy items (SMB left edge, the Rad Racer +hybrid-address render) were investigated and found **already correct**. **AccuracyCoin +141/141 (100%)**, nestest 0-diff — now enforced as an *exact* count, not a 60% floor. + +The **v2.2.6 → v2.3.0** line was a de-monetization + NESdev-remediation run on the v2.0.0 +"Timebase" one-clock scheduler: v2.2.6 "Almanac" made RustyNES permanently open-source and +income-free (ADR 0035; the apps stay free FOSS — no ads, tracking, or paid unlock), and +v2.2.7 "Timbre II" / v2.2.8 "Aperture II" / v2.2.9 "Studio II" / v2.3.0 "Datum II" address +forum-reported audio, presentation, TAS/movie/windowing, and PPU-accuracy items — the core +staying byte-identical except where a change is an intentional, oracle-gated accuracy fix. +The same cycle-accurate core powers the desktop, +browser, Android, iOS, and Libretro builds. Full per-version detail — every release back +through v2.0.0 "Timebase" and the v1.x line — is in [`CHANGELOG.md`](CHANGELOG.md). - **Download:** the [GitHub Releases](https://github.com/doublegate/RustyNES/releases) page — desktop binaries for Linux, macOS (aarch64), and Windows. - **Full per-version history:** [`CHANGELOG.md`](CHANGELOG.md). @@ -929,26 +696,23 @@ powers the desktop, browser, Android, iOS, and Libretro builds. ## Roadmap -With the mobile apps finalized (Android across v2.0.1–v2.0.4, iOS across v2.0.5–v2.0.8) -and re-based onto the improved v2.1.x "Fathom" core, the forward arc keeps **deepening the -project** — accuracy, performance, features, and quality — ahead of the joint mobile store -launch: - -- **v2.1.5 → v2.2.0** — continued deepening of the existing project across accuracy, - performance, features, and quality (the v2.1.5 "Regression Net & Residual" work — a - Holy Mapperel mapper bank-reachability + IRQ regression net wired into CI — is already - under way in `[Unreleased]`). -- **v2.2.6 → v2.3.0** — the **de-monetization + NESdev-remediation** line. v2.2.6 - "Almanac" removes all monetization (RustyNES is permanently open-source and - income-free, ADR 0035; the apps stay free FOSS — no ads, no tracking, no paid - unlock), and v2.2.7 → v2.3.0 address the NESdev-forum feedback (audio aliasing / - VRC6 / Sunsoft 5B; gamma-aware resampling + scanlines; TAStudio + `.bk2` + floating - windows; and the PPU left-edge + hybrid-address accuracy capstone). A **free** mobile - store listing (Google Play / F-Droid / App Store) is a possible **later**, unversioned - step with no monetization attached. - -The exact per-release scope beyond v2.1.4 is planning, not a shipped promise — see the -roadmap for the current framing. +The **v2.2.6 → v2.3.0** line — de-monetization (ADR 0035) plus a pass through +NESdev-forum feedback, all on the v2.0.0 "Timebase" core — is now **complete**: + +- **Shipped:** v2.2.6 "Almanac" (de-monetization; permanently open-source and income-free), + v2.2.7 "Timbre II" (VRC6 / Sunsoft 5B expansion-audio fidelity), v2.2.8 "Aperture II" + (gamma-correct scanlines), v2.2.9 "Studio II" (TAStudio wiring, `.bk2` playback, + tool-window detach), and **v2.3.0 "Datum II"** — true multi-viewport OS-window detach, + the emulator-lock frame-pacing fix, a −5.1% PPU optimization, and the PPU-accuracy + capstone (both forum-reported items verified already-correct), holding + **AccuracyCoin 141/141**. +- **Next:** no line is locked. Candidates include a free-app store launch (no + monetization, per ADR 0035), further frontend performance work, and continued + mapper / accuracy breadth. See [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md). + +A **free** mobile store listing (Google Play / F-Droid / App Store) is a possible later, +unversioned step with **no** monetization attached (ADR 0035). Per-release scope beyond the +current step is planning, not a shipped promise. The longer forward arc lives as research-grounded design plans in [`to-dos/plans/`](to-dos/plans/README.md); see [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) @@ -1041,10 +805,18 @@ RustyNES stands on the shoulders of giants: - The **[Nesdev wiki](https://www.nesdev.org/wiki/)** community for decades of hardware documentation and forum research. -- **[Mesen2](https://github.com/SourMesen/Mesen2)**, - **[higan](https://github.com/higan-emu/higan)**, and - **[ares](https://github.com/ares-emulator/ares)** as the accuracy reference bar and - trace oracles. +- **[Mesen2](https://github.com/SourMesen/Mesen2)** (GPL-3.0-or-later) — the primary + derivation source. RustyNES is a derivative work and incorporates code derived from it + (CPU unstable-store opcodes, the PPU sprite-evaluation / OAM model, ~15 mapper boards, + the Bisqwit NTSC tables, and EEPROM / UNIF / debug-symbol / PGO code). + **[higan](https://github.com/higan-emu/higan)** and + **[ares](https://github.com/ares-emulator/ares)** set the accuracy bar and serve as + behavioral / trace oracles. +- **[puNES](https://github.com/punesemu/puNES)**, + **[FCEUX](https://github.com/TASEmulators/fceux)**, and + **[Nestopia UE](https://github.com/0ldsk00l/nestopia)** (GPL-2.0-or-later) — derivation + for specific subsystems: the puNES FDS drive-timing table, the FCEUX / puNES JV001 / + mapper-147 code, and the Nestopia FME-7 model. - **[TetaNES](https://github.com/lukexor/tetanes)** for the Bus-owns-everything architecture postmortem and Rust patterns. - **[blargg](https://wiki.nesdev.org/w/index.php/Emulator_tests)**, kevtris' nestest, @@ -1057,9 +829,10 @@ RustyNES stands on the shoulders of giants: - **[emu2413](https://github.com/digital-sound-antiques/emu2413)** (Mitsutaka Okazaki, MIT) — the YM2413 / OPLL model behind VRC7 audio — and **[TriCNES](https://github.com/100thCoin/TriCNES)** (Chris Siebert, MIT), the - transistor-level emulator whose PPU / DMA models RustyNES ports and also uses as - a golden oracle. **GeraNES**, FCEUX, Nestopia UE, and puNES served as additional - behavioral oracles. + cycle-accurate C# emulator (a detailed sub-cycle CPU/PPU/APU/DMA state machine) + whose PPU / DMA models RustyNES ports (MIT-licensed, its + source vendored in-repo with attribution) and also uses as a golden oracle. + **GeraNES** (GPL-3.0-only) served as a behavioral oracle — consulted, not incorporated. - The community CRT shaders and NTSC filters whose *looks* RustyNES independently reimplements — **CRT-Royale** (TroggleMonkey), **crt-guest-advanced** (guest.r), **Sony Megatron** (MajorPainInTheCactus), @@ -1079,7 +852,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.9}, + version = {2.3.0}, 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/SUPPORT.md b/SUPPORT.md index 6c0e73f2..a15cb622 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -9,7 +9,7 @@ Thank you for using RustyNES! This document provides guidance on how to get help 1. **Check the Documentation** - [README.md](README.md) - Project overview and quick start - [docs/](docs/) - Comprehensive documentation - - [ROADMAP.md](ROADMAP.md) - Current development status + - [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) - Current development status (the root `ROADMAP.md` is a pre-1.0 historical snapshot) - [FAQ](#frequently-asked-questions) - Common questions (below) 2. **Search Existing Resources** @@ -94,7 +94,7 @@ A: RustyNES is a cycle-accurate NES emulator written in pure Rust, clearing the **Q: Can I use RustyNES now?** -A: Yes. RustyNES is well past its first stable release — the current release is **v2.2.9 "Studio II"** (the head of the v2.2.6 → v2.3.0 line atop the v2.0.0 "Timebase" one-clock scheduler rewrite), a complete, playable desktop application plus native Android / iOS / Libretro builds and a browser build. See [ROADMAP.md](ROADMAP.md) for what shipped and the forward directions. +A: Yes. RustyNES is well past its first stable release — the current release is **v2.3.0 "Datum II"** (the capstone closing the v2.2.6 → v2.3.0 line atop the v2.0.0 "Timebase" one-clock scheduler rewrite), a complete, playable desktop application plus native Android / iOS / Libretro builds and a browser build. See [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) for what shipped and the forward directions. **Q: How accurate is RustyNES?** @@ -116,11 +116,11 @@ A: Native Windows, Linux, and macOS, plus a WebAssembly / GitHub Pages browser b **Q: What ROMs are supported?** -A: iNES and NES 2.0 ROM formats across **51 mapper families** (including expansion audio), the Famicom Disk System (real-BIOS boot), and Vs. System / PlayChoice-10 arcade hardware. Additional mapper families are added demand-driven; see [ROADMAP.md](ROADMAP.md). +A: iNES and NES 2.0 ROM formats across **172 mapper families** (including expansion audio) plus the UNIF (`.unf`) container, the Famicom Disk System (real-BIOS boot), and Vs. System / PlayChoice-10 arcade hardware. Additional mapper families are added demand-driven; see [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md). **Q: Does RustyNES support [feature]?** -A: The feature set includes rollback netplay (2–4 players), RetroAchievements (opt-in), TAS movie record/playback, save-states, rewind, run-ahead, Game Genie + raw-RAM cheats, an egui debugger, Lua scripting, a TAS editor, HD packs, and shader/NTSC filters — plus native Android / iOS / Libretro builds. Check the [ROADMAP.md](ROADMAP.md) for delivered milestones and forward directions. +A: The feature set includes rollback netplay (2–4 players), RetroAchievements (opt-in), TAS movie record/playback, save-states, rewind, run-ahead, Game Genie + raw-RAM cheats, an egui debugger, Lua scripting, a TAS editor, HD packs, and shader/NTSC filters — plus native Android / iOS / Libretro builds. Check [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md) for delivered milestones and forward directions. **Q: Can I embed RustyNES in my project?** diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md index 89837608..9baa9b15 100644 --- a/VERSION-PLAN.md +++ b/VERSION-PLAN.md @@ -1,6 +1,6 @@ # RustyNES Version Plan -**Current release: v2.0.4 "Harbor"** — the head of the v2.0.x "Harbor" mobile-finalization train, atop the **v2.0.0 "Timebase"** MAJOR cut (the one-clock / every-cycle-bus-access scheduler rewrite). **v1.0.0** was the first stable, production cut. `docs/STATUS.md` is the authoritative current-state record; `CHANGELOG.md` carries the full per-release history. +**Current release: v2.3.0 "Datum II"** — the capstone that **closes** the v2.2.6 → v2.3.0 line (true multi-viewport OS-window detach, the emulator-lock frame-pacing fix, a −5.1% byte-identical PPU optimization, and both forum-reported accuracy items verified already-correct) — all on the **v2.0.0 "Timebase"** MAJOR base (the one-clock / every-cycle-bus-access scheduler rewrite). **v1.0.0** was the first stable, production cut. As of **v2.2.9**, RustyNES is **GPL-3.0-or-later** — a derivative work of GPL-licensed emulators (ADR 0036); a licensing correction, **not** a SemVer break (no public-API or save-state change). `docs/STATUS.md` is the authoritative current-state record; `CHANGELOG.md` carries the full per-release history. RustyNES follows [Semantic Versioning 2.0.0](https://semver.org/). @@ -55,7 +55,7 @@ The cycle-accurate engine was integrated as the core in a sequence of documentar | **v0.9.7** | Performance pass (display-sync pacing, dedicated emu thread, audio DRC, run-ahead) | | **v1.0.0** | Production cut — engine + ported desktop UX shell + documentation synthesis | -> **Engine lineage note.** The deep technical history under `docs/` (the `v2.0` master-clock refactor, ADRs, audit logs, the long accuracy program) describes the **upstream engine lineage**. Those old "v1.x"/"v2.x" anchors are engineering history, **not** RustyNES release versions. RustyNES's own release line is v0.1.0 → v0.8.6 → (documentary v0.9.0–v0.9.7) → **v1.0.0** → the v1.1.0–v1.10.0 additive feature line → **v2.0.0 "Timebase"** (the designated MAJOR break) → the v2.0.x "Harbor" line (current: **v2.0.4**). +> **Engine lineage note.** The deep technical history under `docs/` (the `v2.0` master-clock refactor, ADRs, audit logs, the long accuracy program) describes the **upstream engine lineage**. Those old "v1.x"/"v2.x" anchors are engineering history, **not** RustyNES release versions. RustyNES's own release line is v0.1.0 → v0.8.6 → (documentary v0.9.0–v0.9.7) → **v1.0.0** → the v1.1.0–v1.10.0 additive feature line → **v2.0.0 "Timebase"** (the designated MAJOR break) → the v2.0.x "Harbor" line → the v2.1.x "Fathom" accuracy line → the v2.2.x line → **v2.3.0 "Datum II"** (current). ### Post-1.0 release line (v1.1.0 → current) @@ -70,9 +70,15 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide | **v2.0.0 "Timebase"** | **Designated MAJOR break** — one-clock / every-cycle-bus-access scheduler rewrite; `.rns`/`.rnm` epochs bump (ADR 0028); core-level Vs. `DualSystem` support. AccuracyCoin 100% (139/139) | | **v2.0.1 "Harbor"** | First Android re-port onto Timebase + AccuracyCoin oracle re-sync (catalog → 146 rows / 141 assigned tests; briefly 139/141) | | **v2.0.2 – v2.0.3 "Harbor"** | 2-cycle-ALE PPU fetch model promoted to the unconditional default → **AccuracyCoin 100.00% (141/141)** ("ALE + Read" + "Hybrid Addresses" now pass) | -| **v2.0.4 "Harbor"** (current) | Android release candidate — host-only RC scaffolding; core byte-identical to v2.0.3 | +| **v2.0.4 – v2.0.9 "Harbor"** | Mobile finalization on Timebase — Android release candidate (v2.0.4), iOS re-port + polish + App-Store floor (v2.0.5–v2.0.8), both-apps readiness (v2.0.9); host-only, core byte-identical to v2.0.3 | +| **v2.1.0 – v2.1.10 "Fathom"** | Accuracy / display / audio / creator-tools line — palette-backdrop + mapper-tier completion (v2.1.0), the W&W game-DB freeze fix (v2.1.1), display fidelity (v2.1.2 "Prism"), QoL (v2.1.3 "Codex"), accuracy hardening (v2.1.4 "Caliper"), regression net (v2.1.5 "Vernier"), expansion audio (v2.1.6 "Timbre"), hardware revisions (v2.1.7 "Stepping"), performance (v2.1.8 "Tempo"), presentation + CRT shaders (v2.1.9 "Aperture"), creator tools + web parity (v2.1.10 "Loom") — all NTSC byte-identical, **141/141** | +| **v2.2.0 "Capstone"** | Milestone cut closing the v2.1.5 → v2.2.0 "deepen the project" run — netplay matchmaking/lobby + FDS medium model + peripherals & quality/security pass | +| **v2.2.1 – v2.2.5** | Housekeeping (v2.2.1); build / distribution / CI-integrity — libretro buildbot + supply-chain hardening (v2.2.2 "Conduit"); performance + accuracy-closure (v2.2.3 "Datum"); libretro/RetroArch distribution (v2.2.4 "Cartridge"); provenance / licensing / documentation integrity (v2.2.5 "Colophon") | +| **v2.2.6 – v2.2.9** | The **de-monetization + NESdev-remediation** line — RustyNES made permanently open-source and income-free (v2.2.6 "Almanac", ADR 0035); expansion-audio fidelity (v2.2.7 "Timbre II"); gamma-correct presentation (v2.2.8 "Aperture II"); TAS/movie wiring + detachable tool windows + the **relicense to GPL-3.0-or-later** (v2.2.9 "Studio II", ADR 0036) | +| **v2.2.9 "Studio II"** | TAS/movie wiring + the GPL-3.0-or-later relicense — see `CHANGELOG.md` `[2.2.9]` | +| **v2.3.0 "Datum II"** (current) | Head of the v2.x line; **closes** the v2.2.6 → v2.3.0 remediation line. PPU-accuracy capstone — SMB left-edge + hybrid-address (Rad Racer) verified already-correct against the AccuracyCoin oracle and locked with an exact-141/141 regression gate; hybrid-address provenance finalized (doc/oracle-derived); true multi-viewport OS-window detach; the emulator-lock frame-pacing fix; a −5.1% byte-identical PPU optimization — see `CHANGELOG.md` `[2.3.0]` | -> **Forward path.** The remaining v2.0.x "Harbor" steps — **v2.0.5 → v2.0.8** iOS finalization → **v2.0.9** both-apps readiness — lead to **v2.1.0**, the joint Google Play + Apple App Store + AltStore PAL + F-Droid launch. `to-dos/ROADMAP.md` is the authoritative forward roadmap. +> **Forward path.** The v2.0.x "Harbor", v2.1.x "Fathom", and v2.2.x lines have all shipped; the v2.2.6 → v2.3.0 line has now **closed** with v2.3.0 "Datum II"; no successor line is locked (the v2.3.1 → v2.3.4 performance campaign is planned, not committed). RustyNES is **permanently open-source and income-free** (ADR 0035): the earlier "joint Google Play + App Store + AltStore + F-Droid launch" is **withdrawn** — any store listing is a **free** app with **no monetization** (no ads, tracking, or paid unlock), an unversioned later step. `to-dos/ROADMAP.md` is the authoritative forward roadmap. ## Versioning guidelines @@ -87,7 +93,7 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide ## Accuracy milestones (met) -- `nestest` 0-diff, blargg / kevtris suites green, **AccuracyCoin 100.00% (141/141)** on the current v2.0.x line (139/139 at the v1.0.0 cut, before the v2.0.1 oracle re-sync grew the catalog to 141 assigned tests), and a byte-identical 60-ROM commercial regression oracle. `docs/STATUS.md` is the authoritative pass-count source. +- `nestest` 0-diff, blargg / kevtris suites green, **AccuracyCoin 100.00% (141/141)** from **v2.0.3** onward (139/139 at the v1.0.0 cut; the v2.0.1 oracle re-sync grew the catalog to 141 assigned tests and briefly opened two PPU gaps, so v2.0.1–v2.0.2 shipped an honest 139/141 until the v2.0.3 2-cycle-ALE promotion closed them), and a byte-identical 60-ROM commercial regression oracle. As of v2.3.0 the AccuracyCoin gate is pinned to an **exact 141/141** (zero failing tests), so a single-test regression — e.g. in the hybrid-address model — fails CI. `docs/STATUS.md` is the authoritative pass-count source. ## Git tagging diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 01dac968..437f6f18 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -577,6 +577,13 @@ pub struct App { clipboard: Option<arboard::Clipboard>, /// Sprint 5-3 debugger overlay (lazily constructed alongside `Gfx`). debugger: Option<DebuggerOverlay>, + /// v2.3.0 "Datum II" — the true multi-viewport tool-window manager: one real OS + /// window (with its own egui stack + wgpu surface) per detached panel. Empty + /// until the user detaches a panel; reconciled against + /// [`DebuggerOverlay::detached_panels`] each event-loop iteration. Native-only + /// (wasm keeps tool panels docked). + #[cfg(not(target_arch = "wasm32"))] + detached: crate::detached::DetachedManager, /// v1.1.0 beta.3 (Workstream E) — the Lua scripting engine (native, behind /// the `scripting` feature). `None` until a script is loaded. Lives on the /// winit thread (mlua is `!Send`); pumped once per redraw under the emu lock. @@ -885,6 +892,8 @@ impl App { #[cfg(not(target_arch = "wasm32"))] clipboard: None, debugger: None, + #[cfg(not(target_arch = "wasm32"))] + detached: crate::detached::DetachedManager::default(), #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] script: None, #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] @@ -1016,6 +1025,8 @@ impl App { input, config, debugger: None, + #[cfg(not(target_arch = "wasm32"))] + detached: crate::detached::DetachedManager::default(), #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] script: None, #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] @@ -4095,7 +4106,9 @@ impl App { /// "Load from Slot" out under this same condition (`ui_shell::rom_interactive` /// = `rom && !replay_locked`), but the hotkey + `MenuAction` dispatch must /// honour it too — otherwise the greyed item is bypassable via the bound key. - /// Mirrors `GeraNES` `replayInteractionLocked` / `replayRecordingActive`. + /// Behaviour-parity note: the `GeraNES` reference emulator applies an + /// equivalent replay-interaction lockout (observed as a black-box oracle; no + /// code derived from it). fn replay_interaction_locked(&self) -> bool { let emu = self.emu.lock(); emu.movie.is_recording() || emu.movie.is_playing() @@ -6620,7 +6633,23 @@ impl App { // the native body + the `about_to_wait` caller, so allow the lint there. #[cfg_attr(target_arch = "wasm32", allow(clippy::needless_pass_by_ref_mut))] fn pace_frames(&mut self, event_loop: &ActiveEventLoop) { - if self.emu.lock().nes.is_none() { + // v2.3.0 "Datum II" (perf) — answer "is a ROM loaded?" WITHOUT taking the + // emulator mutex. This runs on every `about_to_wait` iteration (a tight spin + // in the wall-clock `Poll` regime), and the emulation thread holds that mutex + // for its whole latch+produce region — so the old unconditional + // `self.emu.lock()` here could block the UI thread for up to a full produce + // (~4 ms), every iteration. `EmuControl::has_rom` is the same fact as a + // lock-free atomic, written by `App` at the very points it starts/stops the + // thread. When no emulation thread exists (feature off, or not yet spawned) + // fall back to the original locked read so behaviour is unchanged there. + #[cfg(all(not(target_arch = "wasm32"), feature = "emu-thread"))] + let no_rom = match self.emu_thread.as_ref() { + Some(thread) => !thread.control().has_rom(), + None => self.emu.lock().nes.is_none(), + }; + #[cfg(not(all(not(target_arch = "wasm32"), feature = "emu-thread")))] + let no_rom = self.emu.lock().nes.is_none(); + if no_rom { // v1.0.0 — no ROM yet: keep the always-on UX shell (menu/status // bar/welcome modal) drawing + interactive. Re-render at ~30 Hz via // `WaitUntil` (so status toasts fade smoothly) rather than an @@ -6797,6 +6826,20 @@ impl App { /// the debugger + the raw-cheat pull. #[cfg(not(target_arch = "wasm32"))] fn post_produce_housekeeping(&mut self) { + // v2.3.0 "Datum II" — refresh detached tool windows once per produced + // frame, but only those whose content actually changes: `Live` panels + // every frame, `Throttled` panels at ~10 Hz, and static `OnInteraction` + // panels not at all here (they repaint from egui's own repaint request on + // interaction). Keeps a wall of detached panels cheap. + // + // This lives HERE rather than in `on_emu_frame` because that function is + // compiled only under the `emu-thread` feature. The synchronous + // production paths (`pace_frames`, the display-sync redraw) run this + // function instead, so anchoring the tick to `on_emu_frame` froze every + // detached window's `Live`/`Throttled` refresh in an `emu-thread`-off + // build. `post_produce_housekeeping` is the one point both regimes share. + self.detached.request_redraw_tick(); + // v2.2.0 — persist the FDS writable disk if it changed this frame. // Cheap when clean / non-FDS (a `disk_is_dirty()` check only). self.flush_fds_save(); @@ -7863,6 +7906,160 @@ fn netplay_status_view(s: &crate::netplay_ui::NetplayStatus) -> crate::debugger: } } +/// v2.3.0 "Datum II" — the multi-viewport tool-window detach plumbing (native). +/// +/// These methods keep the detached-window set (`self.detached`) in sync with the +/// debugger's "detached panels" set and drive one render per detached window per +/// frame. All of it is native-only: wasm is single-canvas and keeps panels docked. +#[cfg(not(target_arch = "wasm32"))] +impl App { + /// Handle a window event addressed to a DETACHED tool window (routed here from + /// [`ApplicationHandler::window_event`] before the main-window path runs). + fn handle_detached_window_event(&mut self, window_id: WindowId, event: &WindowEvent) { + // Feed the window's own egui integration first (pointer / keyboard / focus); + // `repaint` is true when that input changed something egui needs to redraw. + let repaint = self.detached.on_window_event(window_id, event); + match event { + // The OS window's close button reattaches the panel to the main window: + // drop it from the debugger's detached set, then close the window (the + // panel reappears docked next frame). + WindowEvent::CloseRequested => { + if let Some(pid) = self.detached.panel_of(window_id) + && let Some(dbg) = self.debugger.as_mut() + { + dbg.detached_panels_mut().remove(pid); + } + self.detached.close(window_id); + } + WindowEvent::Resized(sz) => { + if let Some(gfx) = self.gfx.as_ref() { + self.detached + .resize(window_id, sz.width, sz.height, &gfx.device); + } + } + WindowEvent::RedrawRequested => self.render_detached_window(window_id), + // Any other event (pointer / keyboard / focus): repaint ONLY if egui + // asked to, so an idle detached window does not spin. The per-frame live + // refresh is driven from `on_emu_frame`, not from here. + _ => { + if repaint { + self.detached.request_redraw(window_id); + } + } + } + } + + /// Render one detached tool window: lock the emulator, then re-run the single + /// target panel into that window's own egui context/surface. + fn render_detached_window(&mut self, window_id: WindowId) { + let Some(target) = self.detached.panel_of(window_id) else { + return; + }; + // Bind disjoint `self` fields to locals so the borrow checker sees the + // detached-manager render (`&mut self.detached`) and the panel body's + // borrows (`self.debugger` / `self.config` / `self.emu`) as non-overlapping. + let Some(gfx) = self.gfx.as_ref() else { + return; + }; + let device = &gfx.device; + let queue = &gfx.queue; + let Some(dbg) = self.debugger.as_mut() else { + return; + }; + let config = &mut self.config; + // Phase 1 — run the panel UI while holding the emulator lock (the panel body + // borrows `&mut Nes`). The lock is scoped to JUST this block. + let prepared = { + let mut emu = self.emu.lock(); + let mut nes = emu.nes.as_mut(); + self.detached.render_ui(window_id, |ui| { + dbg.render_detached_body(ui, target, nes.as_deref_mut(), config); + }) + }; + // Phase 2 — paint + present with the emulator lock RELEASED, so the + // vsync-blocking present never stalls the emulation thread (this is the fix + // for the "slows to a crawl, worse per detached window" report). + if let Some(prepared) = prepared { + self.detached.present(window_id, device, queue, prepared); + } + } + + /// Reconcile the open detached windows against the debugger's detached-panel + /// set: open a real OS window for each newly-detached panel and close the + /// window of any panel that was reattached. Cheap and idempotent; called once + /// per event-loop iteration from [`ApplicationHandler::about_to_wait`]. + fn reconcile_detached(&mut self, event_loop: &ActiveEventLoop) { + // FAST PATH (perf): this runs on EVERY `about_to_wait` iteration — which in + // the wall-clock `Poll` regime is a tight spin — so it must not allocate when + // nothing changed, which is essentially always. Compare set sizes and + // membership without building anything; only the rare real transition + // (a panel just detached or reattached) falls through to the slow path. + match self.debugger.as_ref() { + Some(dbg) => { + let want = dbg.detached_panels(); + if want.len() == self.detached.len() && self.detached.all_panels_in(want) { + return; + } + } + // No debugger: nothing can be detached, so just close any strays. + None => { + if self.detached.is_empty() { + return; + } + } + } + + let desired: Vec<&'static str> = self + .debugger + .as_ref() + .map(|d| d.detached_panels().iter().copied().collect()) + .unwrap_or_default(); + // Open windows for panels detached since the last reconcile. + // + // On failure the panel is REATTACHED rather than left pending. Without + // that, `id` stays in the debugger's detached set while `has_panel(id)` + // stays false, so this loop retries the failing create on every single + // `about_to_wait` iteration — thousands of times a second, each one + // writing a line to stderr. Dropping it back to docked degrades to the + // pre-v2.3.0 behaviour (the panel still works, just in-window), reports + // once, and cannot spin. + let mut failed: Vec<&'static str> = Vec::new(); + for &id in &desired { + if !self.detached.has_panel(id) { + let (title, default_size) = crate::debugger::detached_window_meta(id); + // Prefer the size captured from the docked window at detach time + // (exact match); fall back to the per-panel default. + let size = crate::debugger::take_detached_size(id).unwrap_or(default_size); + let refresh = crate::debugger::detached_refresh(id); + if let Some(gfx) = self.gfx.as_ref() + && let Err(e) = self + .detached + .create(event_loop, gfx, id, title, size, refresh) + { + eprintln!( + "rustynes: failed to open detached window for '{id}': {e} \ + (panel stays docked)" + ); + failed.push(id); + } + } + } + if !failed.is_empty() + && let Some(dbg) = self.debugger.as_mut() + { + for id in failed { + dbg.detached_panels_mut().remove(id); + } + } + // Close windows for panels reattached since the last reconcile. + for id in self.detached.detached_ids() { + if !desired.contains(&id) { + self.detached.close_by_panel(id); + } + } + } +} + impl ApplicationHandler<AppEvent> for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.gfx.is_some() { @@ -7983,9 +8180,19 @@ impl ApplicationHandler<AppEvent> for App { fn window_event( &mut self, event_loop: &ActiveEventLoop, - _window_id: WindowId, + window_id: WindowId, event: WindowEvent, ) { + // v2.3.0 "Datum II" — a detached tool window owns its own egui stack + + // surface, so route its events to the detached manager and never fall + // through to the main window's handler. + #[cfg(not(target_arch = "wasm32"))] + if self.detached.contains_window(window_id) { + self.handle_detached_window_event(window_id, &event); + return; + } + #[cfg(target_arch = "wasm32")] + let _ = window_id; // Forward to the debugger overlay first; if it consumed the event // (e.g. egui textbox focus) we still let the system bindings see // it so global hotkeys keep working. @@ -8582,14 +8789,21 @@ impl ApplicationHandler<AppEvent> for App { ); hd_dims = Some((w, h)); } - // v1.7.1 (#154 review) — re-acquire the lock to hand the debugger - // pass a live `&mut Nes`. The composite is already done (above, - // unlocked), so the lock is now held only across the egui / - // `render_shell` pass — the same discipline the rest of the shell - // follows. This `guard` stays alive until after the present call. - let mut guard = self.emu.lock(); - let emu = &mut *guard; - let nes_for_render = emu.nes.as_mut(); + // v2.3.0 "Datum II" (perf) — the emulator lock is NOT taken here. + // It is acquired below, scoped to phase 1 (the egui UI build, the + // only part needing `&mut Nes`) and released before phase 2 (the + // GPU work). + // + // Through v2.2.9 a guard taken at this point stayed alive "until + // after the present call", so this branch owned the emulator mutex + // across `Surface::get_current_texture` — a blocking wait for a + // free swapchain image, up to a full display refresh — plus the + // encode, submit, and present. Whenever the overlay or any + // `nes`-reading tool panel was open, the emulation thread parked on + // `emu.lock()` in `drive_wallclock` / `drive_one` for that entire + // window and could not produce a frame: the measured cause of the + // stutter and the high produced-interval p99 (worse per additional + // detached window, each of which added its own acquisition). #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] let script_draws = &self.script_draws; #[cfg(all(feature = "scripting", not(target_arch = "wasm32")))] @@ -8635,11 +8849,11 @@ impl ApplicationHandler<AppEvent> for App { let virtual_pad = &mut self.virtual_pad; let index_arg = want_index.then_some(self.present_index_staging.as_slice()); let video_phase = self.present_phase; - let overlay = |device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, - view: &wgpu::TextureView, - size: (u32, u32)| { + // ---- PHASE 1 — build the egui UI under a SCOPED emulator lock ---- + // `run_shell_ui` is the only phase that needs `&mut Nes`, so the + // guard lives exactly as long as this block and is released before + // any GPU work happens. + let prepared = { #[cfg(target_arch = "wasm32")] let extra = |ctx: &egui::Context, cfg: &mut crate::config::Config| { crate::wasm_lobby::show(ctx, wasm_lobby, cfg); @@ -8676,19 +8890,36 @@ impl ApplicationHandler<AppEvent> for App { script_overscan, ); }; - shell_out = debugger.render_shell( - device, - queue, - encoder, + let mut guard = self.emu.lock(); + let nes_for_render = guard.nes.as_mut(); + let (out, prepared) = debugger.run_shell_ui( &window, - view, - size, nes_for_render, config, ui_shell, &shell_frame, extra, ); + shell_out = out; + prepared + }; + // ---- PHASE 2 — paint it, with the emulator lock RELEASED ---- + // Runs inside `Gfx`'s render (after the blocking swapchain + // acquire), so the emulation thread is free to produce throughout. + // + // Debug builds arm a marker for the whole phase: any + // `EmuHandle::lock` reached from here trips a `debug_assert`, + // so a future panel that re-locks inside the paint path fails + // loudly instead of silently restoring the stall this split + // removed. Zero cost in release. + #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] + let _gpu_phase = crate::emu::GpuPhaseGuard::enter(); + let overlay = move |device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + size: (u32, u32)| { + debugger.paint_shell(device, queue, encoder, view, size, prepared); }; // v1.7.1 (#3) — present the upscaled HD buffer when a pack // composited this redraw (the deep-overlay panels still draw on @@ -9478,6 +9709,14 @@ impl ApplicationHandler<AppEvent> for App { event_loop.exit(); return; } + // v2.3.0 "Datum II" — keep the detached tool windows in sync with the + // debugger's detached-panel set (open a real OS window for a newly-detached + // panel, close a reattached one). NOTE: the live per-frame repaint is driven + // from `on_emu_frame` (once per produced emulator frame), NOT here — issuing + // `request_redraw` every `about_to_wait` iteration made the detached windows + // spin at max rate and starved emulation ("slows to a crawl"). + #[cfg(not(target_arch = "wasm32"))] + self.reconcile_detached(event_loop); // Wall-clock pacer. Native: produce up to one frame (with bounded // catch-up) and stay on `Poll`; the actual present happens on the // resulting `RedrawRequested`. wasm32: this is a no-op keep-alive diff --git a/crates/rustynes-frontend/src/av_record.rs b/crates/rustynes-frontend/src/av_record.rs index 7102d417..9ad46f14 100644 --- a/crates/rustynes-frontend/src/av_record.rs +++ b/crates/rustynes-frontend/src/av_record.rs @@ -14,8 +14,7 @@ //! [`crate::emu::EmuCore::produce_one_frame`] has already produced — the same //! data the renderer presents and the audio sink consumes. So the determinism //! contract (same seed + ROM + input ⇒ bit-identical framebuffer + audio) is -//! unaffected, `AccuracyCoin` stays 139/141 (the two newest upstream PPU tests are -//! known gaps), and with the `av-record` feature off +//! unaffected, `AccuracyCoin` stays 141/141, and with the `av-record` feature off //! (the default) this module is not compiled at all — the shipped / wasm / //! `no_std` builds are byte-identical. //! diff --git a/crates/rustynes-frontend/src/cli.rs b/crates/rustynes-frontend/src/cli.rs index 79e16d8e..df1cfdfe 100644 --- a/crates/rustynes-frontend/src/cli.rs +++ b/crates/rustynes-frontend/src/cli.rs @@ -306,7 +306,7 @@ Feature highlights Accuracy Cycle-accurate one-clock, every-cycle-bus-access scheduler (v2.0.0 Timebase); - AccuracyCoin 98.58% (139/141), nestest 0-diff, blargg / kevtris suites green. + AccuracyCoin 100% (141/141), nestest 0-diff, blargg / kevtris suites green. Cartridges & platforms 101+ mapper families, Famicom Disk System, Vs. System / PlayChoice-10, diff --git a/crates/rustynes-frontend/src/debugger/basic_bot_panel.rs b/crates/rustynes-frontend/src/debugger/basic_bot_panel.rs index 8ddfaeb3..07a6a699 100644 --- a/crates/rustynes-frontend/src/debugger/basic_bot_panel.rs +++ b/crates/rustynes-frontend/src/debugger/basic_bot_panel.rs @@ -20,8 +20,6 @@ pub struct BasicBotPanel { seed: u64, /// A status / error line. status: String, - /// v1.8.9 — detached into its own OS window (egui multi-viewport). - detached: bool, /// v1.8.9 — "Run search" was clicked this frame; the caller runs it after the /// render (so `nes` never has to be captured by the viewport callback). run_requested: bool, @@ -36,7 +34,6 @@ impl Default for BasicBotPanel { attempts: 200, seed: 0x1234_5678, status: String::new(), - detached: false, run_requested: false, } } @@ -46,37 +43,27 @@ impl Default for BasicBotPanel { /// held lock; the search is disabled otherwise. pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut BasicBotPanel, nes: Option<&mut Nes>, ) { let can_run = nes.is_some(); - if state.detached { - // v1.8.9 multi-viewport — render in a real OS window via egui's - // `show_viewport_immediate`. The body takes only `can_run` (a `Copy` bool), - // never `nes`, so the FnMut callback captures nothing that has to move. - ctx.show_viewport_immediate( - egui::ViewportId::from_hash_of("rustynes_basic_bot"), - egui::ViewportBuilder::default() - .with_title("BasicBot") - .with_inner_size([340.0, 320.0]), - |vctx, _class| { - // A full-window Area hosts the body without the deprecated - // context-level `CentralPanel::show`. - egui::Area::new(egui::Id::new("basic_bot_detached")) - .show(vctx, |ui| body(ui, state, can_run)); - // The OS window's close button reattaches to the docked panel. - if vctx.input(|i| i.viewport().close_requested()) { - state.detached = false; - } - }, - ); - } else { - egui::Window::new("BasicBot") - .open(open) - .default_width(320.0) - .show(ctx, |ui| body(ui, state, can_run)); - } + // v2.3.0 "Datum II" — routed through the shared detach helper, replacing the + // panel's own bespoke `show_viewport_immediate` (which merely EMBEDDED on the + // single-viewport integration). Detach/reattach is now the shared OS-window path. + super::detachable_window( + ctx, + detached, + "basic_bot", + "BasicBot", + super::WindowCfg { + default_width: Some(320.0), + ..Default::default() + }, + open, + |ui| body(ui, state, can_run), + ); // Run the search AFTER the render — `nes` is free here (not captured by any // closure), so it moves into `run_search` directly, no reborrow needed. if std::mem::take(&mut state.run_requested) { @@ -87,8 +74,6 @@ pub fn show( /// The panel body (config + Run), shared by the docked window and the detached OS /// viewport. A click sets `run_requested`; [`show`] runs the search after rendering. fn body(ui: &mut egui::Ui, state: &mut BasicBotPanel, can_run: bool) { - ui.checkbox(&mut state.detached, "Detach to its own window"); - ui.separator(); ui.horizontal(|ui| { ui.label("Target $"); ui.add(egui::TextEdit::singleline(&mut state.addr_hex).desired_width(60.0)); diff --git a/crates/rustynes-frontend/src/debugger/cheevos_panel.rs b/crates/rustynes-frontend/src/debugger/cheevos_panel.rs index dc191a13..f59f7871 100644 --- a/crates/rustynes-frontend/src/debugger/cheevos_panel.rs +++ b/crates/rustynes-frontend/src/debugger/cheevos_panel.rs @@ -290,20 +290,29 @@ impl CheevosPanelState { #[cfg(all(not(target_arch = "wasm32"), feature = "retroachievements"))] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut CheevosPanelState, config: &crate::config::Config, badges: &mut super::badge_cache::BadgeCache, ) { state.seed(&config.retroachievements.username); - egui::Window::new("RetroAchievements") - .open(open) - .default_pos([560.0, 96.0]) - .default_size([420.0, 460.0]) - .resizable(true) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "cheevos", + "RetroAchievements", + super::WindowCfg { + default_pos: Some([560.0, 96.0]), + default_size: Some([420.0, 460.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { body(ui, state, badges); - }); + }, + ); } /// Variant compiled when the feature is OFF (or on wasm32): an informational @@ -311,23 +320,32 @@ pub fn show( #[cfg(not(all(not(target_arch = "wasm32"), feature = "retroachievements")))] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, _state: &mut CheevosPanelState, _config: &crate::config::Config, ) { - egui::Window::new("RetroAchievements") - .open(open) - .default_pos([560.0, 96.0]) - .default_size([340.0, 130.0]) - .resizable(false) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "cheevos", + "RetroAchievements", + super::WindowCfg { + default_pos: Some([560.0, 96.0]), + default_size: Some([340.0, 130.0]), + resizable: Some(false), + ..Default::default() + }, + open, + |ui| { ui.label(egui::RichText::new("RetroAchievements not built").strong()); ui.label( "This build was compiled without the `retroachievements` \ feature (it is off by default and native-only). Rebuild with \ `--features retroachievements` to enable achievement tracking.", ); - }); + }, + ); } /// v2.7.1 — draw one achievement's badge icon, falling back to the text badge diff --git a/crates/rustynes-frontend/src/debugger/cpu_panel.rs b/crates/rustynes-frontend/src/debugger/cpu_panel.rs index 27d7d1a5..6d189088 100644 --- a/crates/rustynes-frontend/src/debugger/cpu_panel.rs +++ b/crates/rustynes-frontend/src/debugger/cpu_panel.rs @@ -97,8 +97,16 @@ const FLAG_NAMES: [&str; 8] = ["N", "V", "_", "B", "D", "I", "Z", "C"]; /// (the caller queues it on the tracker + keeps the emulator running until it is /// satisfied). #[must_use] +// The argument list is the panel's read-only view of debugger state: the egui +// context, the detached-panel set, the open flag, panel state, the live `Nes`, +// and four independent read-only sources (symbols + their status, the callstack, +// the source map + its status). Bundling them into a struct would add a type +// whose only purpose is to be destructured here, and would still borrow the same +// disjoint fields of `DebuggerOverlay` at the call site. +#[allow(clippy::too_many_arguments)] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut CpuPanelState, nes: &mut Nes, @@ -110,12 +118,21 @@ pub fn show( ) -> Option<StepRequest> { let mut step_request = None; let cpu = nes.cpu_snapshot(); - egui::Window::new("CPU") - .open(open) - .default_pos([8.0, 64.0]) - .default_size([320.0, 360.0]) - .resizable(true) - .show(ctx, |ui| { + // v2.3.0 "Datum II" — routed through the shared detach helper so the CPU window + // can pop out into its own OS window (native) like the other debugger panels. + super::detachable_window( + ctx, + detached, + "cpu", + "CPU", + super::WindowCfg { + default_pos: Some([8.0, 64.0]), + default_size: Some([320.0, 360.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { ui.horizontal(|ui| { ui.monospace(format!("A={:02X}", cpu.a)); ui.monospace(format!("X={:02X}", cpu.x)); @@ -327,7 +344,8 @@ pub fn show( ui.label(rt); } }); - }); + }, + ); step_request } diff --git a/crates/rustynes-frontend/src/debugger/doc_panel.rs b/crates/rustynes-frontend/src/debugger/doc_panel.rs index b45ff638..bc882afb 100644 --- a/crates/rustynes-frontend/src/debugger/doc_panel.rs +++ b/crates/rustynes-frontend/src/debugger/doc_panel.rs @@ -936,7 +936,7 @@ the Debug menu opens its window on the spot; the tool windows are independent of one another. The devtools are output-only and never perturb emulation, so the -determinism contract and AccuracyCoin (98.58%, 139/141) are unaffected. +determinism contract and AccuracyCoin (100%, 141/141) are unaffected. Sub-pages (also in the sidebar tree): - [[CPU & disassembly|dt-cpu]] @@ -1191,11 +1191,11 @@ written in pure Rust (winit + wgpu + cpal + egui). License ...... GPL-3.0-or-later Author ....... DoubleGate - Accuracy ..... AccuracyCoin 98.58% (139/141); nestest 0-diff; + Accuracy ..... AccuracyCoin 100% (141/141); nestest 0-diff; blargg / kevtris suites green. Frontend ..... always-on egui shell, dedicated emulation thread, display-sync pacing, lock-free audio ring. - Features ..... 150 mapper families, FDS, Vs. System / PlayChoice-10, + Features ..... 172 mapper families, FDS, Vs. System / PlayChoice-10, rollback netplay, RetroAchievements, TAS movies + the TAStudio editor, save-states, rewind, run-ahead, Lua scripting + automation, HD packs, A/V recording. diff --git a/crates/rustynes-frontend/src/debugger/hd_pixel_panel.rs b/crates/rustynes-frontend/src/debugger/hd_pixel_panel.rs index 58f9681f..ffd0ece3 100644 --- a/crates/rustynes-frontend/src/debugger/hd_pixel_panel.rs +++ b/crates/rustynes-frontend/src/debugger/hd_pixel_panel.rs @@ -6,8 +6,8 @@ //! and whether each held this frame — ADR 0014), the base (stock) vs final //! (composited) colour, and a blend slider for an original/mod preview value. //! -//! Reference: `GeraNES/.../GeraNESApp.ModPixelInspectorWindowUI.inl` -//! (UX intent only; an independent Rust/egui reimplementation). +//! Reference: the `GeraNES` reference emulator's mod-pixel-inspector window +//! (UX intent only; an independent Rust/egui reimplementation, no code copied). //! //! Builds on the v1.4.0 HD-pack tile-source export + the v1.5.0 //! [`crate::hdpack::HdCompositor::inspect_pixel`] query. Display-only: it reads diff --git a/crates/rustynes-frontend/src/debugger/header_editor.rs b/crates/rustynes-frontend/src/debugger/header_editor.rs index aaa5e85b..d31bdd9e 100644 --- a/crates/rustynes-frontend/src/debugger/header_editor.rs +++ b/crates/rustynes-frontend/src/debugger/header_editor.rs @@ -55,13 +55,25 @@ struct Loaded { } /// Render the Cartridge Info / header-editor window. -pub fn show(ctx: &egui::Context, open: &mut bool, state: &mut HeaderEditorState) { - egui::Window::new("Cartridge Info / Header") - .open(open) - .default_pos([120.0, 80.0]) - .default_size([420.0, 460.0]) - .resizable(true) - .show(ctx, |ui| { +pub fn show( + ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, + open: &mut bool, + state: &mut HeaderEditorState, +) { + super::detachable_window( + ctx, + detached, + "header_editor", + "Cartridge Info / Header", + super::WindowCfg { + default_pos: Some([120.0, 80.0]), + default_size: Some([420.0, 460.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { if ui.button("Open ROM file...").clicked() { open_file(state); } @@ -87,7 +99,8 @@ pub fn show(ctx: &egui::Context, open: &mut bool, state: &mut HeaderEditorState) ui.separator(); ui.weak(&state.status); } - }); + }, + ); } /// The read-only Cartridge Info pane (the small subset, shipped first). diff --git a/crates/rustynes-frontend/src/debugger/input_miniatures_panel.rs b/crates/rustynes-frontend/src/debugger/input_miniatures_panel.rs index e4f653c1..d10599f4 100644 --- a/crates/rustynes-frontend/src/debugger/input_miniatures_panel.rs +++ b/crates/rustynes-frontend/src/debugger/input_miniatures_panel.rs @@ -14,8 +14,8 @@ //! / Bandai Hyper Shot) — with real-time button / axis feedback. With the Four //! Score it shows all four standard pads (multitap). //! -//! Reference: `GeraNES/.../GeraNESApp.InputMiniaturesOverlayUI.inl` -//! (UX/layout intent only; this is an independent Rust/egui reimplementation). +//! Reference: the `GeraNES` reference emulator's input-miniatures overlay +//! (UX/layout intent only; an independent Rust/egui reimplementation, no code copied). //! //! Frontend-only: it reads the same live host-side input snapshot the emulator //! is fed (pushed each frame via diff --git a/crates/rustynes-frontend/src/debugger/input_rebind_panel.rs b/crates/rustynes-frontend/src/debugger/input_rebind_panel.rs index b7866a91..4fd1dd23 100644 --- a/crates/rustynes-frontend/src/debugger/input_rebind_panel.rs +++ b/crates/rustynes-frontend/src/debugger/input_rebind_panel.rs @@ -369,18 +369,27 @@ impl InputPanelState { pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut InputPanelState, config: &mut Config, ) { - egui::Window::new("Input bindings") - .open(open) - .default_pos([560.0, 64.0]) - .default_size([460.0, 520.0]) - .resizable(true) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "input", + "Input bindings", + super::WindowCfg { + default_pos: Some([560.0, 64.0]), + default_size: Some([460.0, 520.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { body(ui, state, config); - }); + }, + ); } /// The input-rebind window body, reusable from the always-on UX shell's diff --git a/crates/rustynes-frontend/src/debugger/mod.rs b/crates/rustynes-frontend/src/debugger/mod.rs index a77f0132..fd43235e 100644 --- a/crates/rustynes-frontend/src/debugger/mod.rs +++ b/crates/rustynes-frontend/src/debugger/mod.rs @@ -262,22 +262,125 @@ pub(crate) struct WindowCfg { pub resizable: Option<bool>, } -/// v2.2.9 "Studio II": render a tool window that the user can **detach** into its -/// own floating OS window — the fix for the "every new window is stuck inside the -/// main window" report (Windows 10). +// v2.3.0 "Datum II" — thread-local "render target" for the multi-viewport detach. +// +// When a detached OS window renders, it re-runs the SAME panel dispatch +// (`chip_panels` / `tool_panels`) that the main window uses, but with this cell set +// to the panel id it hosts. `detachable_window` then renders content ONLY for the +// matching id (filling the OS window) and non-matching panels early-return, so one +// dispatch pass paints exactly one panel per window. `None` (the default) is the +// main-window pass, where every panel renders normally and detached ones render +// nothing docked. See the `crate::detached` module. +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + static DETACH_TARGET: std::cell::Cell<Option<&'static str>> = + const { std::cell::Cell::new(None) }; +} + +/// The current detached render target (`None` on the main-window pass). +#[cfg(not(target_arch = "wasm32"))] +fn current_detach_target() -> Option<&'static str> { + DETACH_TARGET.with(std::cell::Cell::get) +} + +// v2.3.0 — set by `detachable_window` when it actually paints the detached +// target. `render_detached_body` reads it after the dispatch to detect an +// ORPHANED window: a panel whose `show_*` flag was cleared while it was detached +// is never dispatched at all (every call site is `if self.show_x { ... }`), so +// nothing removes it from `detached_panels` and the OS window would linger, +// titled but empty, with no Reattach button. That is reachable without touching +// the window — `clear_tas_editor` fires on every ROM load / close / power cycle, +// and hardcore mode suppresses the Memory panels the same way. +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + static DETACH_PAINTED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; +} + +/// wasm has no detached windows, so the target is always `None`; this stub keeps +/// the gated panel call-sites free of `cfg` noise. +#[cfg(target_arch = "wasm32")] +fn current_detach_target() -> Option<&'static str> { + None +} + +/// RAII guard that sets [`DETACH_TARGET`] for the duration of one detached render +/// and restores `None` on drop (even if the panel body panics). +#[cfg(not(target_arch = "wasm32"))] +struct DetachTargetGuard; + +#[cfg(not(target_arch = "wasm32"))] +impl DetachTargetGuard { + fn new(target: &'static str) -> Self { + DETACH_TARGET.with(|c| c.set(Some(target))); + DETACH_PAINTED.with(|c| c.set(false)); + Self + } + + /// Whether the target panel actually painted during this pass. + fn painted() -> bool { + DETACH_PAINTED.with(std::cell::Cell::get) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for DetachTargetGuard { + fn drop(&mut self) { + DETACH_TARGET.with(|c| c.set(None)); + } +} + +// v2.3.0 — captured docked size (egui points) of a panel at the moment it was +// detached, so its new OS window opens at the SAME footprint the docked window had +// rather than a static guess. `detachable_window` writes here on a Detach click; +// `App` drains it in `reconcile_detached` when it creates the window. A thread-local +// (both the write and the drain run on the winit thread) avoids threading a size map +// through all 18 panel `show()` signatures. +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + static DETACH_SIZES: std::cell::RefCell<std::collections::HashMap<&'static str, [f32; 2]>> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Take (and clear) a just-detached panel's captured docked size. +/// +/// Rounded to whole pixels. `None` if the size was not captured (e.g. the panel was +/// detached programmatically), in which case the caller uses its default from +/// [`detached_window_meta`]. +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub fn take_detached_size(id: &'static str) -> Option<(u32, u32)> { + DETACH_SIZES.with(|m| { + m.borrow_mut() + .remove(id) + // Guard against a degenerate first-frame rect; clamp to a sane minimum. + .map(|[w, h]| (w.max(160.0).round() as u32, h.max(120.0).round() as u32)) + }) +} + +/// v2.3.0 "Datum II": render a tool window that the user can **detach** into its +/// own real floating OS window — the fix for the "every new window is stuck inside +/// the main window" report (Windows 10). /// /// `detached` holds the set of currently-floating panel ids; `id` is this panel's -/// stable key. Docked, it is a normal [`egui::Window`] seeded with `cfg` (the -/// panel's prior first-open position / size / resizability) and a small "⧉ Detach" -/// button. Detached, it renders in a real OS viewport (`show_viewport_immediate`, -/// the same mechanism [`basic_bot_panel`] already uses) with a "⧉ Reattach" -/// button; the OS window's close button reattaches too — the OS window manager -/// sizes/places the detached window, so `cfg` applies to the docked form only. -/// **Native-only** — egui multi-viewport needs winit multi-window, so on wasm it -/// always renders docked. +/// stable key. This function runs in one of two passes, selected by the +/// [`current_detach_target`] thread-local: +/// +/// - **Main-window pass** (`target == None`): if `id` is docked, render the normal +/// [`egui::Window`] seeded with `cfg` (first-open position / size / resizability) +/// plus a small "⧉ Detach" button that adds `id` to `detached`. If `id` is +/// detached, render NOTHING here — it now lives in its own OS window, which the +/// [`crate::detached::DetachedManager`] created when `App` reconciled the +/// `detached` set. +/// - **Detached pass** (`target == Some(t)`): render content ONLY when `id == t`, +/// as a [`egui::CentralPanel`] filling that OS window, with a "⧉ Reattach" +/// button (the OS window's close button reattaches too, handled in `App`). +/// Non-matching panels early-return so one dispatch paints exactly one panel. +/// +/// **Native-only** detach — on wasm (`target` always `None`, no `detached` inserts) +/// it always renders docked. /// /// `add_contents` is the panel body; it captures whatever it needs (`&Nes`, panel -/// state, …) and is called exactly once per frame, in whichever branch is active. +/// state, …) and is called at most once per invocation, in whichever branch runs. // On wasm the detached-viewport branch and the "Detach" button are `#[cfg]`'d // out (egui multi-viewport is unavailable there), so `detached` is never mutated // — `&mut` reads as needless. Keep the native signature and allow it on wasm. @@ -298,46 +401,62 @@ pub(crate) fn detachable_window( #[cfg(target_arch = "wasm32")] let _ = (&detached, id); #[cfg(not(target_arch = "wasm32"))] - if detached.contains(id) { - let mut reattach = false; - // Seed the viewport with the same first-open geometry the docked window - // uses, so a detached panel keeps its size / position / resizability. - let mut vb = egui::ViewportBuilder::default().with_title(title); - if let Some(s) = cfg.default_size { - vb = vb.with_inner_size(s); - } - if let Some(p) = cfg.default_pos { - vb = vb.with_position(p); - } - if let Some(r) = cfg.resizable { - vb = vb.with_resizable(r); + match current_detach_target() { + // Detached render pass: this OS window hosts exactly one panel. Paint the + // matching one to FILL the window with the same themed background the docked + // panel has; every other panel's call early-returns so a single dispatch + // produces a single window's content. A context-level `Area` + a + // central-panel `Frame` is used (not `CentralPanel`, whose egui-0.35 `show` + // needs a `&mut Ui` this function does not receive); `set_min_size(screen)` + // makes it span the whole window and a `ScrollArea` keeps oversized panels + // scrollable. The theme + zoom are applied to this window's context in + // `render_detached_body`, so the frame fill / widget colours match the main + // window rather than egui's bare defaults. + Some(target) => { + if id != target { + return; + } + // Record that the hosted panel produced content this pass; an + // unpainted target means its `show_*` flag was cleared and the window + // must be reattached rather than left as an empty orphan. + DETACH_PAINTED.with(|c| c.set(true)); + let mut reattach = false; + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::new(("detached", id))) + .fixed_pos(screen.min) + .show(ctx, |ui| { + ui.set_min_size(screen.size()); + egui::Frame::central_panel(ui.style()).show(ui, |ui| { + ui.set_min_size(ui.available_size()); + if ui + .button(format!( + "{} Reattach to main window", + crate::icons::glyph::COMPRESS + )) + .clicked() + { + reattach = true; + } + ui.separator(); + egui::ScrollArea::both() + .auto_shrink([false, false]) + .show(ui, add_contents); + }); + }); + if reattach { + // `App` reconciles the `detached` set next iteration and closes the + // now-orphaned OS window (the panel reappears docked). + detached.remove(id); + } + return; } - // NOTE: `show_viewport_immediate` only produces a separate OS window when - // the egui integration enables multi-viewport (`set_embed_viewports(false)` - // + per-viewport winit windows). RustyNES's frontend is currently a - // single-viewport `egui_winit` integration, so egui renders this viewport - // EMBEDDED in the main window. True OS-window detach (the Windows-10 - // trapped-window fix) requires wiring multi-viewport into the render loop - // — tracked as follow-up work; the affordance + geometry are in place for - // when it lands. - ctx.show_viewport_immediate(egui::ViewportId::from_hash_of(id), vb, |vctx, _class| { - // A full-window Area hosts the body (mirrors `basic_bot_panel`, - // avoiding the deprecated context-level `CentralPanel::show`). - egui::Area::new(egui::Id::new(id)).show(vctx, |ui| { - if ui.button("\u{29c9} Reattach to main window").clicked() { - reattach = true; - } - ui.separator(); - add_contents(ui); - }); - if vctx.input(|i| i.viewport().close_requested()) { - reattach = true; + // Main-window pass: a detached panel lives in its own OS window, so render + // nothing docked for it here. + None => { + if detached.contains(id) { + return; } - }); - if reattach { - detached.remove(id); } - return; } let mut win_open = *open; let mut win = egui::Window::new(title).open(&mut win_open); @@ -356,16 +475,144 @@ pub(crate) fn detachable_window( if let Some(r) = cfg.resizable { win = win.resizable(r); } - win.show(ctx, |ui| { + // Only the native build has a Detach affordance, so the click flag (and the + // response we read the docked size from) are native-only. + #[cfg(not(target_arch = "wasm32"))] + let mut detach_clicked = false; + let resp = win.show(ctx, |ui| { + // v2.3.0 — a "pop out into its own OS window" affordance. egui does not let + // us add a button to the native window's title bar, so it sits as a small + // top-row control (the `EXPAND` FA glyph, rendered via the installed Font + // Awesome fallback font — a reliable glyph, unlike the earlier `⧉`). #[cfg(not(target_arch = "wasm32"))] - if ui.small_button("\u{29c9} Detach").clicked() { - detached.insert(id); + if ui + .small_button(format!("{} Detach", crate::icons::glyph::EXPAND)) + .on_hover_text("Pop this panel out into its own window") + .clicked() + { + detach_clicked = true; } add_contents(ui); }); + #[cfg(not(target_arch = "wasm32"))] + if detach_clicked { + // Capture the docked window's current size so the OS window opens matching it. + if let Some(r) = &resp { + let sz = r.response.rect.size(); + DETACH_SIZES.with(|m| m.borrow_mut().insert(id, [sz.x, sz.y])); + } + detached.insert(id); + } + #[cfg(target_arch = "wasm32")] + let _ = &resp; *open = win_open; } +/// v2.3.0 "Datum II" — the OS-window title and default inner size for a detachable +/// tool panel, keyed by its stable `detachable_window` id. +/// +/// `App` calls this when it reconciles the debugger's detached-panel set and needs +/// to spawn a real window (see [`crate::detached::DetachedManager::create`]). The +/// user can freely resize the window afterward; these are only the first-open +/// dimensions. Unknown ids fall back to the id text and a neutral size. Keyed by +/// the `detachable_window` panel id. +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub fn detached_window_meta(id: &'static str) -> (&'static str, (u32, u32)) { + // Sizes track each panel's docked `WindowCfg::default_size` (see the panel + // modules) plus a small allowance for the OS title bar + the reattach row, so a + // detached window opens at roughly the panel's docked footprint rather than one + // uniform oversized default. + match id { + "cpu" => ("CPU", (340, 400)), + "ppu" => ("PPU Viewer", (500, 480)), + "oam" => ("OAM / Sprites", (540, 520)), + "apu" => ("APU", (440, 420)), + "memory" => ("Memory", (540, 580)), + "memory_compare" => ("Memory Compare", (400, 600)), + "trace" => ("Trace Logger", (480, 420)), + "watch" => ("Watch / Breakpoints", (480, 580)), + "event" => ("Event Viewer", (720, 700)), + "nsf" => ("NSF Player", (380, 500)), + "mapper" => ("Mapper", (460, 520)), + "audio_mixer" => ("Audio Mixer", (400, 520)), + "input_display" => ("Input Display", (560, 320)), + "replay" => ("Replay", (380, 360)), + "cheat" => ("Cheats", (460, 440)), + "game_db" => ("Game Database", (560, 480)), + "rom_info" => ("ROM Info", (520, 520)), + "perf" => ("Performance", (560, 440)), + "documentation" => ("Documentation", (780, 560)), + "header_editor" => ("Cartridge Info / Header", (440, 500)), + "script" => ("Lua Script", (440, 380)), + "basic_bot" => ("BasicBot", (360, 420)), + "input" => ("Input bindings", (480, 560)), + "tas" => ("TAStudio", (480, 560)), + "settings" => ("Settings", (440, 480)), + "netplay" => ("Netplay", (420, 380)), + "cheevos" => ("RetroAchievements", (440, 500)), + _ => (id, (520, 440)), + } +} + +/// v2.3.0 "Datum II" — how often a detached tool window needs to repaint. +/// +/// Repainting a detached window costs a brief emulator-lock hold (its panel body +/// reads `&Nes`) plus a GPU frame, so a static panel repainting every frame is pure +/// waste; classifying panels lets us spend that cost only where the display actually +/// changes per frame. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum DetachedRefresh { + /// Continuously-updating live state (registers, RAM, scopes, timing) — repaint + /// every produced emulator frame (~60 Hz). + Live, + /// Slowly-changing status (mapper registers, netplay link health, a playback + /// cursor) — repaint at a throttled ~10 Hz. + Throttled, + /// Static content (a cheat list, ROM metadata, settings) — repaint ONLY when the + /// user interacts with the window (driven by egui's repaint flag), never on the + /// emulator's frame clock. + OnInteraction, +} + +/// Classify a detachable panel's [`DetachedRefresh`] tier by its stable id. +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub fn detached_refresh(id: &'static str) -> DetachedRefresh { + use DetachedRefresh::{Live, OnInteraction, Throttled}; + match id { + // Live per-frame state. + "cpu" | "ppu" | "oam" | "apu" | "memory" | "memory_compare" | "event" | "trace" + | "watch" | "perf" | "audio_mixer" | "input_display" => Live, + // Slowly-changing status / playback progress. + "mapper" | "nsf" | "replay" | "netplay" | "cheevos" | "tas" => Throttled, + // Static / edit-driven panels (cheat, rom_info, game_db, header_editor, + // settings, documentation, script, basic_bot) and anything unknown. + _ => OnInteraction, + } +} + +/// v2.3.0 "Datum II" — the tessellated output of one shell egui pass. +/// +/// Carried from [`DebuggerOverlay::run_shell_ui`] (which needs `&mut Nes`, so the +/// caller holds the emulator lock) to [`DebuggerOverlay::paint_shell`] (pure GPU +/// work, lock released). +/// +/// Splitting the frame at this boundary is what keeps the emulation thread off the +/// emulator mutex while the winit thread blocks in `Surface::get_current_texture` +/// and presents: those can each cost most of a display refresh, and holding the +/// lock across them stalled frame production every rendered frame whenever the +/// overlay or a `nes`-reading tool panel was open. +pub struct PreparedShell { + /// Tessellated draw commands for this frame. + clipped: Vec<egui::ClippedPrimitive>, + /// Textures egui wants uploaded/freed this frame. + textures_delta: egui::TexturesDelta, + /// egui's points-to-pixels scale for this frame. + pixels_per_point: f32, +} + /// State of the debugger overlay. pub struct DebuggerOverlay { /// egui frontend state (window-event integration). @@ -479,6 +726,15 @@ pub struct DebuggerOverlay { /// on wasm it stays empty (multi-viewport is native-only). See /// [`detachable_window`]. detached_panels: std::collections::HashSet<&'static str>, + /// v2.3.0 — last theme applied to each detached window's own egui context. + /// + /// Each detached window has a private `egui::Context`, so the main window's + /// `last_theme` cache cannot speak for it; without per-window tracking the + /// only correct-looking option is to call `apply_theme` every frame, which + /// rebuilds a full `Visuals` and calls `set_visuals` per window per frame. + /// This mirrors `last_theme`, keyed by panel id. + #[cfg(not(target_arch = "wasm32"))] + detached_themes: std::collections::HashMap<&'static str, crate::config::AppTheme>, /// Game Genie cheat panel state (v1.6.0). cheat_ui: cheat_panel::CheatPanelState, /// ROM-database editor panel state (v1.2.0 Workstream B, B4). @@ -690,6 +946,8 @@ impl DebuggerOverlay { #[cfg(not(target_arch = "wasm32"))] show_documentation: false, detached_panels: std::collections::HashSet::new(), + #[cfg(not(target_arch = "wasm32"))] + detached_themes: std::collections::HashMap::new(), cheat_ui: cheat_panel::CheatPanelState::default(), game_db_ui: game_db_panel::GameDbPanelState::default(), rom_info_ui: rom_info_panel::RomInfoPanelState, @@ -1442,6 +1700,78 @@ impl DebuggerOverlay { self.tool_panels(ui.ctx(), Some(nes), config); } + /// v2.3.0 "Datum II" — render ONE detached tool panel into its own OS window. + /// + /// Called by [`crate::detached::DetachedManager::render_ui`] with that + /// window's own egui `Ui`. It sets the thread-local render target to `target` + /// for the duration (via `DetachTargetGuard`, restored on drop), then re-runs + /// the SAME panel dispatch the main window uses: `detachable_window` paints + /// only the matching panel, while every non-detachable panel and the + /// `RetroAchievements` HUD is suppressed by the `main_pass` gate in + /// `chip_panels` / `tool_panels`. Because the dispatch is re-run + /// fresh in this window's own frame, the panel's `&mut Nes` borrow is valid here + /// with no stashed closure and no `unsafe`. + #[cfg(not(target_arch = "wasm32"))] + pub fn render_detached_body( + &mut self, + ui: &mut egui::Ui, + target: &'static str, + mut nes: Option<&mut Nes>, + config: &mut Config, + ) { + let _guard = DetachTargetGuard::new(target); + // v2.3.0 — this window has its OWN egui context, created bare. Apply the + // same theme, UI zoom, and locale the main window applies each frame (see + // `render_shell`) so the detached panel's background fill, widget colours, + // scaling, and text match the docked panel instead of egui's raw defaults — + // the fix for "shading / colouring / layout not maintained once detached". + // + // `apply_theme` rebuilds a whole `Visuals` and calls `set_visuals`, so it + // is gated on an actual change exactly as `render_shell` gates it: the + // theme is per-window state here, tracked in `detached_themes`, because + // each detached context needs its own first-apply even when the main + // window's cached theme is already current. + let ctx = ui.ctx().clone(); + if self.detached_themes.get(target) != Some(&config.ui.theme) { + crate::ui_shell::apply_theme(&ctx, config.ui.theme); + self.detached_themes.insert(target, config.ui.theme); + } + // Both of these are no-ops when the value is unchanged, so they are cheap + // to call unconditionally (matching `render_shell`). + ctx.set_zoom_factor(config.ui.clamped_zoom_factor()); + crate::i18n::set_locale(config.ui.locale); + if let Some(n) = nes.as_deref_mut() { + self.chip_panels(&*ui, n); + } + self.tool_panels(ui.ctx(), nes, config); + + // Orphan check: if the hosted panel never painted, its `show_*` flag was + // cleared while detached (ROM load clears `show_tas`; hardcore mode + // suppresses the Memory panels), so nothing else would ever drop it from + // the detached set. Reattach it — `App::reconcile_detached` closes the now + // unwanted OS window on the next iteration. + if !DetachTargetGuard::painted() { + self.detached_panels.remove(target); + self.detached_themes.remove(target); + } + } + + /// The set of tool-panel ids the user has detached to their own OS window + /// (v2.3.0). `App` reconciles this against the live + /// [`crate::detached::DetachedManager`] each iteration. + #[cfg(not(target_arch = "wasm32"))] + #[must_use] + pub fn detached_panels(&self) -> &std::collections::HashSet<&'static str> { + &self.detached_panels + } + + /// Mutable access to the detached-panel set so `App` can reattach a panel when + /// its OS window is closed (v2.3.0). + #[cfg(not(target_arch = "wasm32"))] + pub fn detached_panels_mut(&mut self) -> &mut std::collections::HashSet<&'static str> { + &mut self.detached_panels + } + /// v1.0.0 — the chip-inspection UI: the CPU / PPU / OAM / APU / Memory / /// Mapper windows. These all read `&mut Nes` and only render when the deep /// overlay is visible. v1.7.0 "Forge" beta.5 (#55) removed the toolbar HUD @@ -1452,6 +1782,11 @@ impl DebuggerOverlay { // `ctx` (floating windows), so only the context handle is needed. let ctx = root_ui.ctx().clone(); let ctx = &ctx; + // v2.3.0 "Datum II" — in a detached render pass only the target panel may + // draw (routed through `detachable_window`, which self-filters by id); the + // non-detachable chip panels below render only in the main-window pass so + // they never leak into another panel's OS window. + let main_pass = current_detach_target().is_none(); // v1.7.0 "Forge" beta.5 (#55) — the `debugger_top` toolbar HUD was // removed: every panel now opens from the always-on menu bar, and the // live read-outs it carried (frame/cycle, fps, movie/disk/netplay @@ -1470,9 +1805,11 @@ impl DebuggerOverlay { if self.show_cpu { // v1.7.0 "Forge" Workstream C — the CPU panel also renders the Call // Stack section (C1) + source-line annotations (C3); a clicked step - // verb is queued on the tracker. + // verb is queued on the tracker. v2.3.0 — detachable, so no `main_pass` + // gate: `detachable_window` self-filters by the render target. let step = cpu_panel::show( ctx, + &mut self.detached_panels, &mut self.show_cpu, &mut self.cpu_ui, nes, @@ -1510,6 +1847,7 @@ impl DebuggerOverlay { if self.show_header_editor { header_editor::show( ctx, + &mut self.detached_panels, &mut self.show_header_editor, &mut self.header_editor_ui, ); @@ -1523,7 +1861,10 @@ impl DebuggerOverlay { nes, ); } - if self.show_memory { + // v2.3.0 — allow the Memory panel in a detached pass ONLY when NOT in + // hardcore mode: in hardcore the detached window renders nothing (the RAM + // viewer stays disabled), while the main pass still shows the placeholder. + if self.show_memory && (main_pass || !self.hardcore_active) { // v2.7.0 — the Memory panel is a RAM hex viewer (a potential // RAM-watch cheat surface), so it is disabled in hardcore mode. if self.hardcore_active { @@ -1557,7 +1898,7 @@ impl DebuggerOverlay { ); } } - if self.show_memory_compare { + if self.show_memory_compare && (main_pass || !self.hardcore_active) { // A cheat-hunting (RAM-search) tool — disabled in hardcore mode for // the same reason as the Memory viewer + cheat panel. if self.hardcore_active { @@ -1626,7 +1967,13 @@ impl DebuggerOverlay { ); } if self.show_script { - script_panel::show(ctx, &mut self.show_script, &mut self.script_ui, nes); + script_panel::show( + ctx, + &mut self.detached_panels, + &mut self.show_script, + &mut self.script_ui, + nes, + ); } if self.show_mapper { mapper_panel::show( @@ -1645,12 +1992,18 @@ impl DebuggerOverlay { /// overlay is visible, so the menu bar can surface them directly. Panels /// that read `nes` (Cheats) no-op when `nes` is `None`. fn tool_panels(&mut self, ctx: &egui::Context, mut nes: Option<&mut Nes>, config: &mut Config) { + // v2.3.0 "Datum II" — a detached render pass paints only its one target + // panel (via `detachable_window`, which self-filters); the non-detachable + // tool panels + the RetroAchievements HUD below render only in the + // main-window pass so they never leak into a tool's OS window. + let main_pass = current_detach_target().is_none(); // v1.8.9 — BasicBot input-search control panel. Renders with the optional // `nes` (reborrowed so the rest of the panels still get it); the search is // disabled when no ROM is loaded. if self.show_basic_bot { basic_bot_panel::show( ctx, + &mut self.detached_panels, &mut self.show_basic_bot, &mut self.basic_bot_ui, nes.as_deref_mut(), @@ -1675,7 +2028,7 @@ impl DebuggerOverlay { // open. The app expires them after a few seconds. v2.7.1 — an // achievement-unlock toast also shows its badge image (left of the text) // once the badge cache has fetched + decoded it. - { + if main_pass { let toasts = self.cheevos_ui.status().toasts.clone(); if !toasts.is_empty() { // v2.7.1 — lazily create + poll the badge cache so unlock-toast @@ -1743,7 +2096,7 @@ impl DebuggerOverlay { // already decodes. They read the inert pushed status snapshot (no `nes` // and no feature gate), so they render in every build configuration; // when the `retroachievements` feature is off the vectors are empty. - { + if main_pass { let status = self.cheevos_ui.status(); // Active challenge indicators + the transient progress indicator, @@ -1851,7 +2204,13 @@ impl DebuggerOverlay { } if self.show_input { - input_rebind_panel::show(ctx, &mut self.show_input, &mut self.input_ui, config); + input_rebind_panel::show( + ctx, + &mut self.detached_panels, + &mut self.show_input, + &mut self.input_ui, + config, + ); } if self.show_input_display { // v1.7.0 "Forge" beta.5 (#51) — the consolidated "Input Display" @@ -1879,6 +2238,7 @@ impl DebuggerOverlay { // edits/seeks as `TasRequest`s the app applies under the emu lock. tastudio_panel::show( ctx, + &mut self.detached_panels, &mut self.show_tas, &mut self.tas_ui, self.tas_editor.as_ref(), @@ -1950,10 +2310,22 @@ impl DebuggerOverlay { ); } if self.show_settings { - settings_panel::show(ctx, &mut self.show_settings, &mut self.settings_ui, config); + settings_panel::show( + ctx, + &mut self.detached_panels, + &mut self.show_settings, + &mut self.settings_ui, + config, + ); } if self.show_netplay { - netplay_panel::show(ctx, &mut self.show_netplay, &mut self.netplay_ui, config); + netplay_panel::show( + ctx, + &mut self.detached_panels, + &mut self.show_netplay, + &mut self.netplay_ui, + config, + ); } if self.show_perf { perf_panel::show( @@ -1986,6 +2358,7 @@ impl DebuggerOverlay { badges.poll(ctx); cheevos_panel::show( ctx, + &mut self.detached_panels, &mut self.show_cheevos, &mut self.cheevos_ui, config, @@ -1993,7 +2366,13 @@ impl DebuggerOverlay { ); } #[cfg(not(all(not(target_arch = "wasm32"), feature = "retroachievements")))] - cheevos_panel::show(ctx, &mut self.show_cheevos, &mut self.cheevos_ui, config); + cheevos_panel::show( + ctx, + &mut self.detached_panels, + &mut self.show_cheevos, + &mut self.cheevos_ui, + config, + ); } } @@ -2094,21 +2473,26 @@ impl DebuggerOverlay { /// /// `nes` is `Option` so the shell renders even before a ROM is loaded; the /// debugger panels are skipped while it is `None`. + /// v2.3.0 "Datum II" — **phase 1** of the shell frame: run the egui UI. + /// + /// This is the ONLY phase that needs `&mut Nes`, so the caller holds the + /// emulator lock across just this call and drops it before + /// [`Self::paint_shell`]. Previously the whole shell frame (egui build **plus** + /// the blocking swapchain acquire, GPU encode, and present) ran inside one + /// emulator-lock hold, so every rendered frame with the overlay or a + /// `nes`-reading tool panel open parked the emulation thread on `emu.lock()` + /// for up to a full display refresh — the root cause of the stutter / high + /// produced-interval p99. See [`PreparedShell`]. #[allow(clippy::too_many_arguments)] - pub fn render_shell<F: FnOnce(&egui::Context, &mut Config)>( + pub fn run_shell_ui<F: FnOnce(&egui::Context, &mut Config)>( &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - encoder: &mut wgpu::CommandEncoder, window: &Arc<Window>, - view: &wgpu::TextureView, - surface_size: (u32, u32), nes: Option<&mut Nes>, config: &mut Config, shell: &mut UiShell, shell_frame: &ShellFrame<'_>, extra_ui: F, - ) -> ShellOutput { + ) -> (ShellOutput, PreparedShell) { // v1.7.1 — re-derive visibility from the live chip-panel state BEFORE // the egui pass so closing the last chip panel hides the overlay again // (see `recompute_visible`); otherwise `visible` would latch on forever @@ -2248,15 +2632,40 @@ impl DebuggerOverlay { let pixels_per_point = ctx.pixels_per_point(); let clipped = ctx.tessellate(output.shapes, pixels_per_point); + ( + shell_out, + PreparedShell { + clipped, + textures_delta: output.textures_delta, + pixels_per_point, + }, + ) + } + + /// v2.3.0 "Datum II" — **phase 2** of the shell frame: paint the prepared egui + /// output into `view`. + /// + /// Pure GPU work — it never touches the emulator, so the caller MUST have + /// dropped the emulator lock before calling this (it runs after the blocking + /// swapchain acquire inside [`crate::gfx::Gfx`]'s render path). + pub fn paint_shell( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + surface_size: (u32, u32), + prepared: PreparedShell, + ) { let screen_desc = egui_wgpu::ScreenDescriptor { size_in_pixels: [surface_size.0.max(1), surface_size.1.max(1)], - pixels_per_point, + pixels_per_point: prepared.pixels_per_point, }; - for (id, image) in output.textures_delta.set { + for (id, image) in prepared.textures_delta.set { self.renderer.update_texture(device, queue, id, &image); } self.renderer - .update_buffers(device, queue, encoder, &clipped, &screen_desc); + .update_buffers(device, queue, encoder, &prepared.clipped, &screen_desc); { let mut rp = encoder .begin_render_pass(&wgpu::RenderPassDescriptor { @@ -2276,11 +2685,39 @@ impl DebuggerOverlay { multiview_mask: None, }) .forget_lifetime(); - self.renderer.render(&mut rp, &clipped, &screen_desc); + self.renderer + .render(&mut rp, &prepared.clipped, &screen_desc); } - for id in output.textures_delta.free { + for id in prepared.textures_delta.free { self.renderer.free_texture(&id); } + } + + /// Convenience wrapper running both shell phases back-to-back. + /// + /// Used by the common (debugger-hidden) present path, which already copies the + /// framebuffer under a brief lock and renders with the emulator lock RELEASED — + /// so there is nothing to gain from splitting the phases there. The + /// overlay-visible path calls [`Self::run_shell_ui`] / [`Self::paint_shell`] + /// separately so the lock never spans the GPU work. + #[allow(clippy::too_many_arguments)] + pub fn render_shell<F: FnOnce(&egui::Context, &mut Config)>( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + window: &Arc<Window>, + view: &wgpu::TextureView, + surface_size: (u32, u32), + nes: Option<&mut Nes>, + config: &mut Config, + shell: &mut UiShell, + shell_frame: &ShellFrame<'_>, + extra_ui: F, + ) -> ShellOutput { + let (shell_out, prepared) = + self.run_shell_ui(window, nes, config, shell, shell_frame, extra_ui); + self.paint_shell(device, queue, encoder, view, surface_size, prepared); shell_out } } diff --git a/crates/rustynes-frontend/src/debugger/netplay_panel.rs b/crates/rustynes-frontend/src/debugger/netplay_panel.rs index 2e39d831..b1b441f9 100644 --- a/crates/rustynes-frontend/src/debugger/netplay_panel.rs +++ b/crates/rustynes-frontend/src/debugger/netplay_panel.rs @@ -220,6 +220,7 @@ impl NetplayPanelState { #[cfg(not(target_arch = "wasm32"))] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut NetplayPanelState, config: &mut crate::config::Config, @@ -229,14 +230,22 @@ pub fn show( &config.netplay.last_join_address, config.netplay.num_players, ); - egui::Window::new("Netplay") - .open(open) - .default_pos([600.0, 96.0]) - .default_size([400.0, 320.0]) - .resizable(true) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "netplay", + "Netplay", + super::WindowCfg { + default_pos: Some([600.0, 96.0]), + default_size: Some([400.0, 320.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { body(ui, state, config); - }); + }, + ); } /// wasm32 variant: netplay needs `std::net`, which is absent in the browser, @@ -244,16 +253,24 @@ pub fn show( #[cfg(target_arch = "wasm32")] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, _state: &mut NetplayPanelState, _config: &mut crate::config::Config, ) { - egui::Window::new("Netplay") - .open(open) - .default_pos([600.0, 96.0]) - .default_size([320.0, 120.0]) - .resizable(false) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "netplay", + "Netplay", + super::WindowCfg { + default_pos: Some([600.0, 96.0]), + default_size: Some([320.0, 120.0]), + resizable: Some(false), + ..Default::default() + }, + open, + |ui| { ui.label(egui::RichText::new("Use the \"Netplay (browser)\" panel").strong()); ui.label( "This UDP netplay panel is native-only (a browser cannot open a \ @@ -269,7 +286,8 @@ pub fn show( ) .weak(), ); - }); + }, + ); } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/rustynes-frontend/src/debugger/script_panel.rs b/crates/rustynes-frontend/src/debugger/script_panel.rs index df8995e6..af8c1e37 100644 --- a/crates/rustynes-frontend/src/debugger/script_panel.rs +++ b/crates/rustynes-frontend/src/debugger/script_panel.rs @@ -99,12 +99,25 @@ impl ScriptPanelState { /// Render the script console. #[allow(clippy::needless_pass_by_ref_mut)] // uniform chip-panel signature. -pub fn show(ctx: &egui::Context, open: &mut bool, state: &mut ScriptPanelState, _nes: &mut Nes) { - egui::Window::new("Lua Script") - .open(open) - .default_size([420.0, 320.0]) - .resizable(true) - .show(ctx, |ui| { +pub fn show( + ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, + open: &mut bool, + state: &mut ScriptPanelState, + _nes: &mut Nes, +) { + super::detachable_window( + ctx, + detached, + "script", + "Lua Script", + super::WindowCfg { + default_size: Some([420.0, 320.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { if !state.available { ui.colored_label( egui::Color32::from_rgb(230, 180, 80), @@ -153,5 +166,6 @@ pub fn show(ctx: &egui::Context, open: &mut bool, state: &mut ScriptPanelState, ui.monospace(line); } }); - }); + }, + ); } diff --git a/crates/rustynes-frontend/src/debugger/settings_panel.rs b/crates/rustynes-frontend/src/debugger/settings_panel.rs index bc8e8d0c..187da127 100644 --- a/crates/rustynes-frontend/src/debugger/settings_panel.rs +++ b/crates/rustynes-frontend/src/debugger/settings_panel.rs @@ -302,16 +302,24 @@ impl SettingsPanelState { #[cfg(not(target_arch = "wasm32"))] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut SettingsPanelState, config: &mut Config, ) { - egui::Window::new("Settings") - .open(open) - .default_pos([560.0, 64.0]) - .default_size([420.0, 420.0]) - .resizable(true) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "settings", + "Settings", + super::WindowCfg { + default_pos: Some([560.0, 64.0]), + default_size: Some([420.0, 420.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { body(ui, state, config); ui.separator(); ui.horizontal(|ui| { @@ -325,7 +333,8 @@ pub fn show( if !state.status.is_empty() { ui.label(state.status.clone()); } - }); + }, + ); } /// wasm32 variant: identical UI but no filesystem persistence (the panel @@ -333,20 +342,29 @@ pub fn show( #[cfg(target_arch = "wasm32")] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut SettingsPanelState, config: &mut Config, ) { - egui::Window::new("Settings") - .open(open) - .default_pos([560.0, 64.0]) - .default_size([420.0, 420.0]) - .resizable(true) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "settings", + "Settings", + super::WindowCfg { + default_pos: Some([560.0, 64.0]), + default_size: Some([420.0, 420.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { body(ui, state, config); ui.separator(); ui.label("(config save unavailable on web — changes are in-memory only)"); - }); + }, + ); } /// The full settings body: graphics / audio / rewind sections, rendered one @@ -1041,8 +1059,9 @@ fn paint_palette_preview(ui: &mut egui::Ui, base: &[[u8; 3]; 64]) { } } -/// v1.2.0 C2 — the composable shader-stack editor + preset bank UI (mirrors -/// `GeraNES`' `ShaderWindowUI.inl`). +/// v1.2.0 C2 — the composable shader-stack editor + preset bank UI (a +/// shader-parameter UI in the style of the `GeraNES` reference emulator's, +/// independently built on egui). /// /// Lives inside a collapsing header so it does not clutter the Graphics tab. /// Every mutation flags `state.apply.shader_stack` so the app rebuilds the live diff --git a/crates/rustynes-frontend/src/debugger/tastudio_panel.rs b/crates/rustynes-frontend/src/debugger/tastudio_panel.rs index e013ec51..2e9157eb 100644 --- a/crates/rustynes-frontend/src/debugger/tastudio_panel.rs +++ b/crates/rustynes-frontend/src/debugger/tastudio_panel.rs @@ -222,15 +222,23 @@ const FUTURE_PAD: usize = 8; #[allow(clippy::too_many_lines)] pub fn show( ctx: &egui::Context, + detached: &mut std::collections::HashSet<&'static str>, open: &mut bool, state: &mut TasStudioPanelState, editor: Option<&TasEditor>, ) { - egui::Window::new("TAStudio") - .open(open) - .default_size([460.0, 520.0]) - .resizable(true) - .show(ctx, |ui| { + super::detachable_window( + ctx, + detached, + "tas", + "TAStudio", + super::WindowCfg { + default_size: Some([460.0, 520.0]), + resizable: Some(true), + ..Default::default() + }, + open, + |ui| { let Some(editor) = editor else { ui.weak("No TAStudio session. Load a ROM and open TAStudio from Tools."); return; @@ -251,7 +259,8 @@ pub fn show( macros(ui, state, editor); ui.separator(); grid(ui, state, editor); - }); + }, + ); } /// v1.8.9 — the input-macro / pattern bank: record a pattern from the cursor and diff --git a/crates/rustynes-frontend/src/detached.rs b/crates/rustynes-frontend/src/detached.rs new file mode 100644 index 00000000..4a898c32 --- /dev/null +++ b/crates/rustynes-frontend/src/detached.rs @@ -0,0 +1,432 @@ +//! v2.3.0 "Datum II" — true multi-viewport tool-window detach. +//! +//! This module lets a tool panel (PPU viewer, Cheats, Audio Mixer, …) leave the +//! main window and live in its **own real OS window** — the fix for the Windows-10 +//! "every tool window is trapped inside the main window" report. It replaces the +//! v2.2.9 stopgap, where `egui`'s `show_viewport_immediate` merely *embedded* the +//! panel inside the single main viewport (`RustyNES`'s `egui_winit` integration was +//! single-viewport, so the immediate viewport never became a separate window). +//! +//! ## Why a separate module instead of egui's native multi-viewport +//! +//! egui *does* have a native multi-viewport path (`set_embed_viewports(false)` + +//! `Context::set_immediate_viewport_renderer`). But an *immediate* viewport is +//! rendered by a re-entrant callback that runs **inside** the parent's render pass +//! and must create a winit window on the spot — which needs `&ActiveEventLoop`. +//! That reference is only valid during event dispatch, so `eframe` erases its +//! lifetime into a `'static` thread-local behind `unsafe`. A *deferred* viewport +//! avoids that, but its callback is `Fn(&Context) + Send + Sync + 'static`, so it +//! cannot borrow the live `&mut Nes` a debugger panel needs. +//! +//! `RustyNES` deliberately avoids both. Each detached window here owns its own egui +//! [`egui::Context`] / [`egui_winit::State`] / [`egui_wgpu::Renderer`] / +//! [`wgpu::Surface`] and is rendered on **its own** `RedrawRequested`, in its own +//! stack frame, where the panel's borrows (`&mut Nes`, panel state) are freshly +//! re-acquired by re-running the panel dispatch under a thread-local render-target +//! filter (see [`crate::debugger::DebuggerOverlay::render_detached_body`]). Nothing is stashed +//! across frames, so **no `unsafe` and no lifetime erasure are required**. +//! +//! ## GPU resource sharing +//! +//! Every detached window shares the main [`crate::gfx::Gfx`]'s one +//! [`wgpu::Instance`] / [`wgpu::Adapter`] / [`wgpu::Device`] / [`wgpu::Queue`] +//! (retained on `Gfx` for exactly this purpose). Sharing the device is not just an +//! optimization: an `egui_wgpu::Renderer`'s textures and buffers must live on the +//! same device its render pass targets, so a second device would force +//! cross-device texture copies egui does not perform. Only the per-window surface +//! and egui state are new. +//! +//! Native-only: wasm is single-canvas and keeps every tool panel docked, so this +//! whole module is `#[cfg(not(target_arch = "wasm32"))]` and never compiled there. + +use std::collections::HashMap; +use std::sync::Arc; + +use winit::dpi::LogicalSize; +use winit::event::WindowEvent; +use winit::event_loop::ActiveEventLoop; +use winit::window::{Window, WindowAttributes, WindowId}; + +/// One detached tool panel, hosted in its own OS window with a private egui stack. +/// +/// The `window` `Arc` keeps the winit window (and therefore the surface's backing +/// handle) alive for the surface's whole lifetime — which is why +/// [`crate::gfx::Gfx::create_detached_surface`] can hand back a `'static` surface. +struct DetachedWindow { + /// The OS window. `Arc` because the surface borrows its display/window handle + /// for `'static`; we hold the only other reference. + window: Arc<Window>, + /// This window's own swapchain surface (created from the shared instance). + surface: wgpu::Surface<'static>, + /// Live surface configuration (tracks size for reconfigure on resize). + config: wgpu::SurfaceConfiguration, + /// This window's own egui frontend state (input + platform output). + state: egui_winit::State, + /// This window's own egui render pipeline (wgpu-backed), targeting `config.format`. + renderer: egui_wgpu::Renderer, + /// The stable panel id this window hosts (e.g. `"ppu"`); the render-target key. + panel_id: &'static str, + /// How often this panel needs to repaint on the emulator's frame clock (v2.3.0). + refresh: crate::debugger::DetachedRefresh, +} + +/// Owns every detached tool window and the panel-id ⇄ window-id mapping. +/// +/// Lives on `App` (native only). The main event loop routes window events here by +/// [`WindowId`], reconciles the open set against the debugger's "detached panels" +/// set each iteration, and drives one render per window per frame. +#[derive(Default)] +pub struct DetachedManager { + /// Every live detached window, keyed by its OS window id. + windows: HashMap<WindowId, DetachedWindow>, + /// Reverse index: panel id → its window id (for reconcile / close-by-panel). + by_panel: HashMap<&'static str, WindowId>, + /// Monotonic tick advanced once per produced emulator frame; drives the + /// `Throttled` refresh tier's ~10 Hz cadence (v2.3.0). + throttle_tick: u32, +} + +/// The egui output of one detached window's `run_ui` pass. +/// +/// Carried from [`DetachedManager::render_ui`] (which needs the emulator lock, +/// because the panel body borrows `&mut Nes`) to [`DetachedManager::present`] (which +/// does NOT — it is pure GPU work + a vsync-blocking present). This lets `App` drop the +/// emulator lock BEFORE the blocking present, so N detached windows never serialize +/// the emulation thread behind N vsync waits (the "slows to a crawl" bug). +pub struct PreparedDetachedFrame { + shapes: Vec<egui::epaint::ClippedShape>, + textures_delta: egui::TexturesDelta, +} + +impl DetachedManager { + /// True if `id` is one of our detached windows (so the main event handler must + /// route the event here instead of treating it as the main window). + #[must_use] + pub fn contains_window(&self, id: WindowId) -> bool { + self.windows.contains_key(&id) + } + + /// True if `panel` currently has an open detached window. + #[must_use] + pub fn has_panel(&self, panel: &'static str) -> bool { + self.by_panel.contains_key(panel) + } + + /// Number of open detached windows. + #[must_use] + pub fn len(&self) -> usize { + self.by_panel.len() + } + + /// True when no tool panel is detached (the overwhelmingly common case, and the + /// cheapest possible reconcile fast-path). + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_panel.is_empty() + } + + /// True when every open window's panel is still in `desired` — an + /// allocation-free membership check used by `App`'s per-iteration reconcile + /// fast-path. Combined with an equal length this proves the two sets match. + #[must_use] + pub fn all_panels_in(&self, desired: &std::collections::HashSet<&'static str>) -> bool { + self.by_panel.keys().all(|id| desired.contains(id)) + } + + /// The panel id hosted by window `id`, if it is one of ours. + #[must_use] + pub fn panel_of(&self, id: WindowId) -> Option<&'static str> { + self.windows.get(&id).map(|w| w.panel_id) + } + + /// The set of panel ids that currently have a detached window (owned copy so + /// the caller can mutate `self` while iterating). + #[must_use] + pub fn detached_ids(&self) -> Vec<&'static str> { + self.by_panel.keys().copied().collect() + } + + /// Advance the frame tick and repaint each detached window according to its + /// [`crate::debugger::DetachedRefresh`] tier: `Live` panels every produced frame + /// (~60 Hz), `Throttled` panels at ~10 Hz, and `OnInteraction` panels never here + /// (they repaint only when the user interacts, via the egui repaint flag). Called + /// once per produced emulator frame. This is the "smart update rate" pass that + /// keeps a wall of static detached panels from each costing a lock hold + GPU + /// frame every 16 ms. + pub fn request_redraw_tick(&mut self) { + // ~10 Hz throttle at the NTSC ~60 Hz frame rate (every 6th produced frame). + const THROTTLE_DIV: u32 = 6; + self.throttle_tick = self.throttle_tick.wrapping_add(1); + let throttled_now = self.throttle_tick.is_multiple_of(THROTTLE_DIV); + for w in self.windows.values() { + let repaint = match w.refresh { + crate::debugger::DetachedRefresh::Live => true, + crate::debugger::DetachedRefresh::Throttled => throttled_now, + crate::debugger::DetachedRefresh::OnInteraction => false, + }; + if repaint { + w.window.request_redraw(); + } + } + } + + /// Create a new detached OS window hosting `panel_id`. + /// + /// Builds the winit window from `event_loop`, a swapchain surface from the + /// shared [`crate::gfx::Gfx`] instance/adapter/device, and a fresh egui + /// `Context`/`State`/`Renderer` mirroring [`crate::debugger::DebuggerOverlay`]'s + /// setup (Font Awesome installed, root viewport, matching renderer options). + /// + /// # Errors + /// Returns a message if the OS refuses the window or the surface can't be made. + pub fn create( + &mut self, + event_loop: &ActiveEventLoop, + gfx: &crate::gfx::Gfx, + panel_id: &'static str, + title: &str, + size: (u32, u32), + refresh: crate::debugger::DetachedRefresh, + ) -> Result<(), String> { + if self.by_panel.contains_key(panel_id) { + return Ok(()); // already open — idempotent + } + let attrs = WindowAttributes::default() + .with_title(title) + .with_inner_size(LogicalSize::new(size.0.max(1), size.1.max(1))); + let window = Arc::new( + event_loop + .create_window(attrs) + .map_err(|e| format!("create detached window: {e}"))?, + ); + let (surface, config) = gfx + .create_detached_surface(window.clone()) + .map_err(|e| format!("create detached surface: {e}"))?; + + // Mirror DebuggerOverlay::new so the detached panel is themed/scaled and + // renders identically to its docked form. + let ctx = egui::Context::default(); + crate::icons::install(&ctx); + let viewport_id = ctx.viewport_id(); + let state = egui_winit::State::new(ctx, viewport_id, &*window, None, None, None); + let renderer = egui_wgpu::Renderer::new( + &gfx.device, + config.format, + egui_wgpu::RendererOptions { + msaa_samples: 1, + depth_stencil_format: None, + dithering: false, + predictable_texture_filtering: false, + }, + ); + + let id = window.id(); + window.request_redraw(); + self.windows.insert( + id, + DetachedWindow { + window, + surface, + config, + state, + renderer, + panel_id, + refresh, + }, + ); + self.by_panel.insert(panel_id, id); + Ok(()) + } + + /// Close the detached window `id` (drops its window, surface, and egui state). + pub fn close(&mut self, id: WindowId) { + if let Some(w) = self.windows.remove(&id) { + self.by_panel.remove(w.panel_id); + } + } + + /// Close the detached window hosting `panel` (used by reconcile when the panel + /// was reattached from inside the main window). + pub fn close_by_panel(&mut self, panel: &'static str) { + if let Some(id) = self.by_panel.remove(panel) { + self.windows.remove(&id); + } + } + + /// Feed a window event to the hosting window's egui integration (pointer / + /// keyboard / focus) and report whether egui now wants a repaint. The caller + /// uses that to `request_redraw` ONLY on interaction, so an idle detached window + /// does not spin (the per-frame live refresh is driven separately, once per + /// produced emulator frame). + pub fn on_window_event(&mut self, id: WindowId, event: &WindowEvent) -> bool { + if let Some(w) = self.windows.get_mut(&id) { + w.state.on_window_event(&w.window, event).repaint + } else { + false + } + } + + /// Request a repaint of one detached window (used after an interaction the egui + /// integration flagged as repaint-worthy). + pub fn request_redraw(&self, id: WindowId) { + if let Some(w) = self.windows.get(&id) { + w.window.request_redraw(); + } + } + + /// Reconfigure window `id`'s surface after an OS resize. + pub fn resize(&mut self, id: WindowId, width: u32, height: u32, device: &wgpu::Device) { + if let Some(w) = self.windows.get_mut(&id) { + w.config.width = width.max(1); + w.config.height = height.max(1); + w.surface.configure(device, &w.config); + } + } + + /// Phase 1 of a detached window's frame: run its egui UI. This is the ONLY part + /// that needs the emulator lock (the `build` panel body borrows `&mut Nes`), so + /// the caller holds the lock across just this call and drops it before + /// [`Self::present`]. Returns the tessellation input, or `None` if the window is + /// gone. + pub fn render_ui( + &mut self, + id: WindowId, + build: impl FnMut(&mut egui::Ui), + ) -> Option<PreparedDetachedFrame> { + let w = self.windows.get_mut(&id)?; + let raw = w.state.take_egui_input(&w.window); + let ctx = w.state.egui_ctx().clone(); + let output = ctx.run_ui(raw, build); + w.state + .handle_platform_output(&w.window, output.platform_output); + + // Honour egui's own repaint request. `run_ui` reports, per viewport, how + // long it is willing to wait before the next frame; a ZERO delay means it + // wants another frame immediately — a hover highlight or active-widget + // colour mid-transition, a spinner, a text cursor blink. + // + // This matters most for the `OnInteraction` refresh tier, which is the one + // tier that never re-arms from the emulator frame clock. Without this, an + // animation that egui started would stop partway and only resume on the + // next user input, leaving the widget stuck in a half-lit state. + if output + .viewport_output + .values() + .any(|v| v.repaint_delay.is_zero()) + { + w.window.request_redraw(); + } + + Some(PreparedDetachedFrame { + shapes: output.shapes, + textures_delta: output.textures_delta, + }) + } + + /// Phase 2 of a detached window's frame: tessellate + paint + present. Does NOT + /// touch the emulator, so the caller MUST drop the emulator lock before calling + /// this — the `frame.present()` here blocks on vsync (Fifo), and holding the + /// lock across it is what starved the emulation thread. + /// + /// The color attachment is **cleared** (not `Load`ed): a detached window has no + /// NES framebuffer underneath, so the panel draws onto a solid backdrop. + pub fn present( + &mut self, + id: WindowId, + device: &wgpu::Device, + queue: &wgpu::Queue, + prepared: PreparedDetachedFrame, + ) { + let Some(w) = self.windows.get_mut(&id) else { + return; + }; + let ctx = w.state.egui_ctx().clone(); + + // Apply egui's texture delta BEFORE anything that can bail out. + // + // The delta is ONE-SHOT: egui hands over each created/updated texture + // exactly once and then forgets it. Skipping a frame's `set` list (as an + // early `return` on a lost swapchain would) means the font atlas and any + // newly-created image never reach the renderer, and every later frame + // draws with a texture id the renderer has never seen — the window stays + // blank or garbled for the rest of its life, long after the transient + // surface error cleared. Uploading first costs nothing on the happy path + // and makes a skipped present merely a dropped frame. + for (tid, image) in prepared.textures_delta.set { + w.renderer.update_texture(device, queue, tid, &image); + } + + // Acquire the swapchain image (wgpu 29 `CurrentSurfaceTexture` enum), with + // the same reconfigure-on-lost / skip-otherwise policy as `Gfx`. Frees are + // deferred to the end so a bail-out cannot drop a texture the next frame + // still references. + let frame = match w.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(t) + | wgpu::CurrentSurfaceTexture::Suboptimal(t) => t, + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + w.surface.configure(device, &w.config); + Self::free_textures(&mut w.renderer, prepared.textures_delta.free); + return; + } + _ => { + Self::free_textures(&mut w.renderer, prepared.textures_delta.free); + return; + } + }; + let view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("detached-egui-encoder"), + }); + + let pixels_per_point = ctx.pixels_per_point(); + let clipped = ctx.tessellate(prepared.shapes, pixels_per_point); + let screen_desc = egui_wgpu::ScreenDescriptor { + size_in_pixels: [w.config.width.max(1), w.config.height.max(1)], + pixels_per_point, + }; + w.renderer + .update_buffers(device, queue, &mut encoder, &clipped, &screen_desc); + { + let mut rp = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("detached-egui-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.05, + g: 0.05, + b: 0.05, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + w.renderer.render(&mut rp, &clipped, &screen_desc); + } + Self::free_textures(&mut w.renderer, prepared.textures_delta.free); + queue.submit(Some(encoder.finish())); + frame.present(); + } + + /// Release the textures egui retired this frame. + /// + /// Factored out so every exit path from [`Self::present`] — including the + /// swapchain bail-outs — runs it exactly once. Leaking these would grow GPU + /// memory for the window's lifetime. + fn free_textures(renderer: &mut egui_wgpu::Renderer, free: Vec<egui::TextureId>) { + for tid in free { + renderer.free_texture(&tid); + } + } +} diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index cdc2b42b..1d285386 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -105,12 +105,67 @@ impl EmuHandle { /// thread must not wedge the other (the core's state is a plain value; /// the next frame either works or panics identically). pub fn lock(&self) -> std::sync::MutexGuard<'_, EmuCore> { + #[cfg(all(debug_assertions, not(target_arch = "wasm32")))] + debug_assert!( + !gpu_phase_active(), + "EmuHandle::lock() called from the GPU phase of a frame. v2.3.0 split the \ + shell render into `run_shell_ui` (holds this lock) and `paint_shell` (must \ + not), because holding it across the blocking swapchain acquire + present \ + parked the emulation thread for up to a full display refresh every frame. \ + Re-locking inside the paint/overlay path silently reintroduces that stall \ + with no other symptom than the stutter coming back." + ); self.inner .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } } +// v2.3.0 "Datum II" — debug-only guard for the phase-1/phase-2 lock-scope +// invariant introduced when `render_shell` was split. +// +// The split is load-bearing for frame pacing but is enforced by nothing except +// care: today the native `extra` closure deliberately uses the `ss_dir` / `ss_sha` +// values snapshotted *before* the pass precisely so it need not re-lock, and a +// future panel that re-locked inside the paint path would compile fine, pass every +// test, and quietly restore the stall. This makes that mistake fail loudly in debug +// builds. Zero cost in release (`debug_assertions` off) and never compiled on wasm, +// which has no emulation thread to starve. +#[cfg(all(debug_assertions, not(target_arch = "wasm32")))] +thread_local! { + static GPU_PHASE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) }; +} + +/// True while the calling thread is inside a frame's GPU phase. +#[cfg(all(debug_assertions, not(target_arch = "wasm32")))] +#[must_use] +pub fn gpu_phase_active() -> bool { + GPU_PHASE.with(std::cell::Cell::get) +} + +/// RAII marker for the GPU phase of a frame — construct it around the +/// paint/encode/present region so any `EmuHandle::lock` inside trips the +/// debug assertion above. +#[cfg(all(debug_assertions, not(target_arch = "wasm32")))] +pub struct GpuPhaseGuard; + +#[cfg(all(debug_assertions, not(target_arch = "wasm32")))] +impl GpuPhaseGuard { + /// Enter the GPU phase. + #[must_use] + pub fn enter() -> Self { + GPU_PHASE.with(|c| c.set(true)); + Self + } +} + +#[cfg(all(debug_assertions, not(target_arch = "wasm32")))] +impl Drop for GpuPhaseGuard { + fn drop(&mut self) { + GPU_PHASE.with(|c| c.set(false)); + } +} + /// The emulation thread (increment 3) moves an `EmuHandle` across a thread /// boundary, which requires `EmuCore: Send`. `Box<dyn Mapper>` is `Send` /// (the `Mapper` trait bounds it) and the core holds no `Rc`/`Cell`, so it diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs index fe03cb24..301461d0 100644 --- a/crates/rustynes-frontend/src/emu_thread.rs +++ b/crates/rustynes-frontend/src/emu_thread.rs @@ -300,6 +300,20 @@ impl EmuControl { self.has_rom.store(on, Ordering::Release); } + /// Whether a ROM is currently loaded, read lock-free. + /// + /// v2.3.0 "Datum II" (perf): the winit thread's `pace_frames` needs this on + /// EVERY `about_to_wait` iteration. Reading it from the emulator mutex meant the + /// UI thread could block for up to a full produce (~4 ms) whenever the emulation + /// thread happened to hold the lock — per iteration, in a spin loop. This atomic + /// is written by `App` at the same points it starts/stops the thread, so it is + /// the authoritative answer and costs one `Acquire` load, which pairs with the + /// `Release` store in `set_has_rom`. + #[must_use] + pub fn has_rom(&self) -> bool { + self.has_rom.load(Ordering::Acquire) + } + /// Set the active regime + per-region frame duration (from /// `App::resolve_pacing`). pub fn set_regime(&self, regime: u8, frame: Duration) { diff --git a/crates/rustynes-frontend/src/gfx.rs b/crates/rustynes-frontend/src/gfx.rs index acc20626..53c30fd3 100644 --- a/crates/rustynes-frontend/src/gfx.rs +++ b/crates/rustynes-frontend/src/gfx.rs @@ -284,6 +284,17 @@ pub struct Gfx { /// Reference-counted handle to the underlying winit window. Held to /// keep the surface valid for as long as we render. pub window: Arc<Window>, + /// v2.3.0 "Datum II" — the wgpu instance is retained so additional + /// per-window surfaces can be created for detached tool windows + /// (true multi-viewport OS-window detach). `create_surface` needs the + /// live instance; the main path never touches it after init. + #[cfg(not(target_arch = "wasm32"))] + instance: wgpu::Instance, + /// v2.3.0 "Datum II" — the adapter is retained so a detached-window + /// surface can be configured against the same GPU (format / alpha / + /// present-mode capabilities). Shared, read-only after init. + #[cfg(not(target_arch = "wasm32"))] + adapter: wgpu::Adapter, /// Surface configuration (width + height, current present mode, ...). surface: wgpu::Surface<'static>, /// Wgpu device — pub so the debugger overlay + ntsc filter can share @@ -725,6 +736,10 @@ impl Gfx { Ok(Self { window, + #[cfg(not(target_arch = "wasm32"))] + instance, + #[cfg(not(target_arch = "wasm32"))] + adapter, surface, device, queue, @@ -753,6 +768,74 @@ impl Gfx { }) } + /// v2.3.0 "Datum II" — create a wgpu surface + configuration for a + /// *detached* tool window (the true multi-viewport / OS-window detach). + /// + /// This reuses the main window's [`wgpu::Instance`] and [`wgpu::Adapter`] + /// (retained in `Gfx` for exactly this purpose) plus the shared + /// [`wgpu::Device`]/[`wgpu::Queue`], so every OS window renders on ONE GPU + /// context. Sharing the device is not merely an optimization: each + /// detached window builds its own [`egui_wgpu::Renderer`], and an egui + /// renderer's textures/buffers must live on the same device its render + /// pass targets — a second device would force cross-device texture copies + /// egui does not support. The returned surface is `'static` because the + /// caller holds the backing `Arc<Window>` alive for the surface's whole + /// lifetime inside the `DetachedManager`. + /// + /// Native-only: wasm is single-canvas and keeps tool panels docked. + #[cfg(not(target_arch = "wasm32"))] + pub fn create_detached_surface( + &self, + window: Arc<Window>, + ) -> Result<(wgpu::Surface<'static>, wgpu::SurfaceConfiguration), GfxError> { + let size = window.inner_size(); + let surface = self + .instance + .create_surface(window) + .map_err(|e| GfxError::Surface(e.to_string()))?; + let caps = surface.get_capabilities(&self.adapter); + // The retained adapter was selected with `compatible_surface` pointing at + // the MAIN window's surface, so it is not *guaranteed* to be able to + // present to this newly created one. When it cannot, wgpu reports empty + // capability vectors — and indexing `[0]` would panic the whole emulator + // while merely opening a tool window. Fail this one detached window + // instead; `DetachedManager::create` already turns the error into a log + // line and the panel falls back to docked. + let Some(&first_format) = caps.formats.first() else { + return Err(GfxError::Surface( + "retained adapter reports no supported formats for the detached surface".into(), + )); + }; + let Some(&alpha_mode) = caps.alpha_modes.first() else { + return Err(GfxError::Surface( + "retained adapter reports no supported alpha modes for the detached surface".into(), + )); + }; + // Prefer an sRGB surface format so egui's linear->sRGB present encode + // matches the main window; fall back to whatever the surface offers. + let format = caps + .formats + .iter() + .copied() + .find(wgpu::TextureFormat::is_srgb) + .unwrap_or(first_format); + let config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width.max(1), + height: size.height.max(1), + // Detached tool windows are UI chrome, not the paced emulation + // surface, so plain vsync (`Fifo`, guaranteed-supported) is right: + // they do not beat against the wall-clock frame pacer. + present_mode: wgpu::PresentMode::Fifo, + alpha_mode, + view_formats: vec![], + desired_maximum_frame_latency: 2, + }; + surface.configure(&self.device, &config); + Ok((surface, config)) + } + /// v2.8.0 Phase 2 — live present-mode switch for the pacing matrix /// (e.g. entering display-sync needs `Fifo`; leaving it restores the /// configured preference). Returns `false` (and leaves the surface diff --git a/crates/rustynes-frontend/src/icons.rs b/crates/rustynes-frontend/src/icons.rs index 5178f540..c3da33fe 100644 --- a/crates/rustynes-frontend/src/icons.rs +++ b/crates/rustynes-frontend/src/icons.rs @@ -1,8 +1,8 @@ //! `FontAwesome` menu icons (v1.2.0 Workstream H3). //! //! Adds Font Awesome 6 Free **Solid** glyphs in front of the menu-bar labels, -//! mirroring the `GeraNES` `withMenuIcon` model (an icon before each top menu and -//! its items). This is a pure cosmetic layer: the glyphs are font codepoints +//! following the common icon-before-each-menu-item convention (as the `GeraNES` +//! reference emulator also does). This is a pure cosmetic layer: the glyphs are font codepoints //! prepended to existing labels, so if the font fails to register the labels //! still render as plain text (egui falls back to the proportional font for any //! glyph the icon font lacks) — it never crashes. @@ -41,8 +41,8 @@ const FA_FAMILY: &str = "fa-solid"; /// Font Awesome 6 Free **Solid** glyph codepoints used by the menu bar. /// -/// Each is the Unicode scalar value of a FA Solid icon (the same glyphs `GeraNES` -/// uses in `FontAwesomeIcons.h`). They live in the Unicode Private Use Area, so +/// Each is the Unicode scalar value of a FA Solid icon (the same well-known FA +/// glyphs the `GeraNES` reference emulator uses for its menus). They live in the Unicode Private Use Area, so /// they never collide with real text and degrade to a missing-glyph box (or /// nothing) if the font is unavailable. pub mod glyph { @@ -122,8 +122,10 @@ pub mod glyph { pub const PALETTE: char = '\u{f53f}'; /// `tv` — display / aspect / overscan / window size. pub const TV: char = '\u{f26c}'; - /// `expand` — fullscreen. + /// `expand` — fullscreen / pop a tool panel out into its own OS window. pub const EXPAND: char = '\u{f065}'; + /// `compress` — reattach a detached tool panel back into the main window. + pub const COMPRESS: char = '\u{f066}'; /// `bars` — show/hide menu bar. pub const BARS: char = '\u{f0c9}'; /// `bug` — top-level Debug menu / CPU debugger. diff --git a/crates/rustynes-frontend/src/lib.rs b/crates/rustynes-frontend/src/lib.rs index f4dc738a..7d8a2f8e 100644 --- a/crates/rustynes-frontend/src/lib.rs +++ b/crates/rustynes-frontend/src/lib.rs @@ -40,6 +40,11 @@ pub mod cheats; pub mod cli; pub mod config; pub mod debugger; +// v2.3.0 "Datum II" — true multi-viewport tool-window detach: each detached tool +// panel gets its own real OS window with a private egui stack. Native-only (wasm +// is single-canvas and keeps panels docked). +#[cfg(not(target_arch = "wasm32"))] +pub mod detached; // v2.8.0 Phase 5 — the emulation core extracted from `App` (per-frame // produce state; the boundary the emulation thread spawns onto). pub mod emu; diff --git a/crates/rustynes-frontend/src/shader_pass.rs b/crates/rustynes-frontend/src/shader_pass.rs index f653dcbb..512e5168 100644 --- a/crates/rustynes-frontend/src/shader_pass.rs +++ b/crates/rustynes-frontend/src/shader_pass.rs @@ -1,6 +1,7 @@ #![allow(clippy::too_many_arguments, clippy::doc_markdown)] -//! Composable post-process shader stack (v1.2.0 C2, GeraNES `ShaderPass`-inspired). +//! Composable post-process shader stack (v1.2.0 C2, following the RetroArch +//! multi-pass shader-preset model, as the `GeraNES` reference emulator also does). //! //! This module turns the frontend's single-select post-process filter (the //! mutually-exclusive CRT / NTSC / composite-rt chain in [`crate::gfx`]) into a @@ -34,9 +35,9 @@ //! // #pragma parameter <name> "<label>" <default> <min> <max> <step> //! ``` //! -//! [`parse_pragma_parameters`] parses them (mirroring GeraNES' -//! `parseShaderParameters` in `ShaderWindowUI.inl`) to drive generic egui -//! sliders, and the per-pass parameter overrides persist in the config. +//! [`parse_pragma_parameters`] parses them (following RetroArch's documented +//! `#pragma parameter` convention) to drive generic egui sliders, and the +//! per-pass parameter overrides persist in the config. use std::collections::BTreeMap; @@ -44,7 +45,7 @@ use serde::{Deserialize, Serialize}; /// One tunable parameter declared by a shader via a `#pragma parameter` header. /// -/// Mirrors GeraNES' `ShaderPass::Parameter` (RetroArch's parameter convention): +/// Follows RetroArch's documented `#pragma parameter` convention: /// `#pragma parameter <name> "<label>" <default> <min> <max> <step>`. #[derive(Debug, Clone, PartialEq)] pub struct ShaderParam { @@ -72,8 +73,8 @@ pub struct ShaderParam { /// ``` /// /// `min`/`max`/`step` are optional and default to `0.0` / `1.0` / `0.01` when -/// absent. Malformed lines are skipped. This mirrors GeraNES' -/// `parseShaderParameters` (`GeraNESApp.ShaderWindowUI.inl`). +/// absent. Malformed lines are skipped. This follows RetroArch's documented +/// `#pragma parameter` convention. #[must_use] pub fn parse_pragma_parameters(src: &str) -> Vec<ShaderParam> { let mut out = Vec::new(); diff --git a/crates/rustynes-frontend/src/ui_shell.rs b/crates/rustynes-frontend/src/ui_shell.rs index 426d868d..320fa546 100644 --- a/crates/rustynes-frontend/src/ui_shell.rs +++ b/crates/rustynes-frontend/src/ui_shell.rs @@ -517,15 +517,18 @@ impl UiShell { let keys = config.input.system.clone(); // v1.2.0 Workstream H1 — per-item contextual enable predicates derived - // from the live frame state, mirroring GeraNES `MenuUI.inl`: + // from the live frame state, mirroring the `GeraNES` reference + // emulator's menu-gating behavior: // // - `rom_change_restricted`: while a netplay session is active the // loaded ROM must not change under the rollback session — disables - // Open ROM / Open Recent (GeraNES `netplayRomChangeRestricted`). + // Open ROM / Open Recent (the `GeraNES` reference emulator restricts + // ROM changes during netplay the same way). // - `replay_locked`: while a TAS movie is recording OR playing back, the // session owns the input/state timeline — disables load-state and the // reset/power-cycle/disk actions that would desync the replay - // (GeraNES `replayInteractionLocked` / `replayRecordingActive`). The + // (matching the `GeraNES` reference emulator's replay-interaction + // lockout). The // per-item Record/Play gating below additionally distinguishes the // recording-vs-playing case (you can't Record over a Playback, etc.). // @@ -659,8 +662,8 @@ impl UiShell { } // (H1) Load-state restores the timeline — forbidden while a // movie is recording (rewrites the recording) OR playing - // back (desyncs playback). Mirrors GeraNES - // `replayRecordingActive` / `replayInteractionLocked`. + // back (desyncs playback). Mirrors the `GeraNES` + // reference emulator's replay recording/interaction lockout. if accel_enabled( ui, rom_interactive, @@ -1228,8 +1231,9 @@ impl UiShell { } } // (H1) Opening the Netplay panel is locked while a replay - // (TAS movie) owns the session. Mirrors GeraNES Netplay - // gating (`!replayInteractionLocked`). + // (TAS movie) owns the session. Mirrors the `GeraNES` + // reference emulator's Netplay gating (no replay-interaction + // lockout active). #[cfg(not(target_arch = "wasm32"))] if ui .add_enabled( diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info index b43bce08..317ed548 100644 --- a/crates/rustynes-libretro/rustynes_libretro.info +++ b/crates/rustynes-libretro/rustynes_libretro.info @@ -3,9 +3,9 @@ display_name = "Nintendo - NES / Famicom (RustyNES)" authors = "DoubleGate" supported_extensions = "nes|fds" corename = "RustyNES" -license = "GPL-3.0-or-later" +license = "GPLv3" permissions = "" -display_version = "v2.2.9" +display_version = "v2.3.0" categories = "Emulator" # Hardware Information diff --git a/crates/rustynes-mappers/src/homebrew_boards.rs b/crates/rustynes-mappers/src/homebrew_boards.rs index 6bab9547..7c74f53d 100644 --- a/crates/rustynes-mappers/src/homebrew_boards.rs +++ b/crates/rustynes-mappers/src/homebrew_boards.rs @@ -11,7 +11,7 @@ //! alongside PRG and CHR so a game can double-buffer whole screens. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/jaleco_discrete.rs b/crates/rustynes-mappers/src/jaleco_discrete.rs index 106d5382..7b08d5a2 100644 --- a/crates/rustynes-mappers/src/jaleco_discrete.rs +++ b/crates/rustynes-mappers/src/jaleco_discrete.rs @@ -10,7 +10,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m038_bitcorp38.rs b/crates/rustynes-mappers/src/m038_bitcorp38.rs index 78d1eb6e..22d2ee41 100644 --- a/crates/rustynes-mappers/src/m038_bitcorp38.rs +++ b/crates/rustynes-mappers/src/m038_bitcorp38.rs @@ -6,7 +6,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m039_subor39.rs b/crates/rustynes-mappers/src/m039_subor39.rs index d71095f0..6da3a171 100644 --- a/crates/rustynes-mappers/src/m039_subor39.rs +++ b/crates/rustynes-mappers/src/m039_subor39.rs @@ -5,7 +5,7 @@ //! conflict, no IRQ. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m041_caltron41.rs b/crates/rustynes-mappers/src/m041_caltron41.rs index bcb6d6b3..1498b353 100644 --- a/crates/rustynes-mappers/src/m041_caltron41.rs +++ b/crates/rustynes-mappers/src/m041_caltron41.rs @@ -9,7 +9,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs index c439d2e0..8a049e68 100644 --- a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs +++ b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs @@ -13,7 +13,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m089_sunsoft2.rs b/crates/rustynes-mappers/src/m089_sunsoft2.rs index 31b047a7..46677424 100644 --- a/crates/rustynes-mappers/src/m089_sunsoft2.rs +++ b/crates/rustynes-mappers/src/m089_sunsoft2.rs @@ -133,8 +133,10 @@ impl Mapper for Sunsoft2 { fn cpu_write(&mut self, addr: u16, value: u8) { if (0x8000..=0xFFFF).contains(&addr) { // The Sunsoft-2 board has **bus conflicts** (nesdev INES_Mapper_089 - // marks the register "BUS CONFLICTS"; GeraNES `Mapper089::writePrg` - // does `data &= readPrg(addr)`). The register shares the address + // marks the register "BUS CONFLICTS"; the AND-with-ROM-byte masking + // was also cross-referenced against the `GeraNES` reference + // emulator — documented behavior, independently expressed here, no + // code copied). The register shares the address // space with PRG-ROM, so a store drives the written byte ANDed with // the ROM byte already present at that address. *Tenka no Goikenban: // Mito Koumon* relies on this — without the mask the raw value diff --git a/crates/rustynes-mappers/src/m093_sunsoft3r.rs b/crates/rustynes-mappers/src/m093_sunsoft3r.rs index 2c2de24c..29d48585 100644 --- a/crates/rustynes-mappers/src/m093_sunsoft3r.rs +++ b/crates/rustynes-mappers/src/m093_sunsoft3r.rs @@ -118,9 +118,11 @@ impl Mapper for Sunsoft3r { // disagreed. The register shares the address space with PRG-ROM, so // a store drives the written byte ANDed with the ROM byte already at // that address. Same treatment as the sibling Sunsoft-2 board in - // `m089_sunsoft2.rs`, and matching the designated reference - // `GeraNES/src/GeraNES/Mappers/Mapper093.h`, whose - // `writePrg` opens with `data &= readPrg(addr);`. + // `m089_sunsoft2.rs`. The AND-with-ROM-byte masking is the + // nesdev-documented bus-conflict behavior and was cross-referenced + // against the `GeraNES` reference emulator; the Rust below is an + // independent expression of that documented behavior (no code + // copied). // Decode every field from the masked value. let value = value & self.read_prg(addr); // [.PPP ...E]: bits 4-6 = 16K PRG bank, bit 0 = CHR-RAM enable. diff --git a/crates/rustynes-mappers/src/m096_bandai96.rs b/crates/rustynes-mappers/src/m096_bandai96.rs index aa9f4316..d0d348e6 100644 --- a/crates/rustynes-mappers/src/m096_bandai96.rs +++ b/crates/rustynes-mappers/src/m096_bandai96.rs @@ -9,7 +9,7 @@ //! this board needs a PPU-read hook where its peers need none. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m107_magic_dragon107.rs b/crates/rustynes-mappers/src/m107_magic_dragon107.rs index ab47de85..367c3a44 100644 --- a/crates/rustynes-mappers/src/m107_magic_dragon107.rs +++ b/crates/rustynes-mappers/src/m107_magic_dragon107.rs @@ -5,7 +5,7 @@ //! bank. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m113_ave_nina006.rs b/crates/rustynes-mappers/src/m113_ave_nina006.rs index fd1e5b8f..33616618 100644 --- a/crates/rustynes-mappers/src/m113_ave_nina006.rs +++ b/crates/rustynes-mappers/src/m113_ave_nina006.rs @@ -8,7 +8,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m132_txc_22211.rs b/crates/rustynes-mappers/src/m132_txc_22211.rs index d74887f3..0bef1264 100644 --- a/crates/rustynes-mappers/src/m132_txc_22211.rs +++ b/crates/rustynes-mappers/src/m132_txc_22211.rs @@ -170,7 +170,8 @@ impl Mapper for Txc132 { fn cpu_read(&mut self, addr: u16) -> u8 { match addr { 0x4100..=0x5FFF => { - // GeraNES decodes the read on (addr & 0x0103) == 0x0100. + // The read is decoded on (addr & 0x0103) == 0x0100 + // (cross-referenced against the `GeraNES` reference emulator). if (addr & 0x0103) == 0x0100 { self.txc.read() & 0x0F } else { diff --git a/crates/rustynes-mappers/src/m156_daou156.rs b/crates/rustynes-mappers/src/m156_daou156.rs index c00c4f6a..ae2bde46 100644 --- a/crates/rustynes-mappers/src/m156_daou156.rs +++ b/crates/rustynes-mappers/src/m156_daou156.rs @@ -7,7 +7,7 @@ //! addresses. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m180_nichibutsu180.rs b/crates/rustynes-mappers/src/m180_nichibutsu180.rs index ba76848e..fabc7ceb 100644 --- a/crates/rustynes-mappers/src/m180_nichibutsu180.rs +++ b/crates/rustynes-mappers/src/m180_nichibutsu180.rs @@ -7,7 +7,7 @@ //! Writes are subject to a bus conflict, as on any ungated discrete board. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m185_cnrom185.rs b/crates/rustynes-mappers/src/m185_cnrom185.rs index 10f53cc2..08601b27 100644 --- a/crates/rustynes-mappers/src/m185_cnrom185.rs +++ b/crates/rustynes-mappers/src/m185_cnrom185.rs @@ -10,7 +10,7 @@ //! Stock CNROM is in `m003_cnrom.rs`. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs index 6fbfa206..f79367ec 100644 --- a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs +++ b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs @@ -10,7 +10,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m240_cne_multicart.rs b/crates/rustynes-mappers/src/m240_cne_multicart.rs index 7fcbe66d..95da80cb 100644 --- a/crates/rustynes-mappers/src/m240_cne_multicart.rs +++ b/crates/rustynes-mappers/src/m240_cne_multicart.rs @@ -7,7 +7,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m241_bxrom241.rs b/crates/rustynes-mappers/src/m241_bxrom241.rs index 84dbfb7f..f2730226 100644 --- a/crates/rustynes-mappers/src/m241_bxrom241.rs +++ b/crates/rustynes-mappers/src/m241_bxrom241.rs @@ -7,7 +7,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m244_cne_decathlon.rs b/crates/rustynes-mappers/src/m244_cne_decathlon.rs index 6159f74b..3bac2941 100644 --- a/crates/rustynes-mappers/src/m244_cne_decathlon.rs +++ b/crates/rustynes-mappers/src/m244_cne_decathlon.rs @@ -7,7 +7,7 @@ //! A discrete-logic board in the shape of the stock mappers (`NROM`, `CNROM`, //! `UxROM`, `GxROM`, `AxROM`): bank-select latch registers, no IRQ, no on-cart //! audio. Banking / mirroring semantics are cross-checked against the -//! `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, and validated by register-decode + save-state unit //! tests. //! diff --git a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs index 5715b4f4..35c7f829 100644 --- a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs +++ b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs @@ -6,7 +6,7 @@ //! register, a write above it is save RAM. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/m250_nitra250.rs b/crates/rustynes-mappers/src/m250_nitra250.rs index bab5fe59..d6785924 100644 --- a/crates/rustynes-mappers/src/m250_nitra250.rs +++ b/crates/rustynes-mappers/src/m250_nitra250.rs @@ -7,7 +7,7 @@ //! of its size class carries none. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/multicart_discrete.rs b/crates/rustynes-mappers/src/multicart_discrete.rs index c784e54e..acf69194 100644 --- a/crates/rustynes-mappers/src/multicart_discrete.rs +++ b/crates/rustynes-mappers/src/multicart_discrete.rs @@ -14,7 +14,7 @@ //! it is why these decode paths look address-driven rather than value-driven. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/ntdec.rs b/crates/rustynes-mappers/src/ntdec.rs index 59a94f7e..3f414ff3 100644 --- a/crates/rustynes-mappers/src/ntdec.rs +++ b/crates/rustynes-mappers/src/ntdec.rs @@ -14,7 +14,7 @@ //! see also `sachen_8259.rs` for the comparable Sachen family. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/sachen_8259.rs b/crates/rustynes-mappers/src/sachen_8259.rs index ddd9e2f4..8786c8cf 100644 --- a/crates/rustynes-mappers/src/sachen_8259.rs +++ b/crates/rustynes-mappers/src/sachen_8259.rs @@ -9,7 +9,7 @@ //! bit-permutation rather than by separate decode paths. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-mappers/src/sachen_discrete.rs b/crates/rustynes-mappers/src/sachen_discrete.rs index 7e137c6c..5e07ec4b 100644 --- a/crates/rustynes-mappers/src/sachen_discrete.rs +++ b/crates/rustynes-mappers/src/sachen_discrete.rs @@ -17,7 +17,7 @@ //! `sachen_8259.rs`. //! //! A best-effort (Tier-2) board: register-decode correctness verified against -//! the `GeraNES` reference (`GeraNES/src/GeraNES/Mappers/Mapper0NN.h`) +//! the `GeraNES` reference emulator (cross-referenced, not copied) //! and the nesdev wiki, with no commercial-oracle ROM in the tree. Banking math //! is direct slice indexing and every bank select wraps with `% count`, so a //! register write can never index out of bounds -- required for the `#![no_std]` diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 7f734750..634cfb91 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -503,7 +503,8 @@ pub struct Ppu { // to the inactive rest defaults), NOT an ADR-0028 save-state format-epoch break. // // Ported from TriCNES (`TriCNES/Emulator.cs`, MIT, commit 9199870), - // the AccuracyCoin author's own transistor-level emulator, which is the + // the AccuracyCoin author's own cycle-accurate C# emulator (a detailed + // sub-cycle CPU/PPU/APU/DMA state machine), which is the // ground-truth oracle for the "ALE + Read" / "Hybrid Addresses" tests (the // vendored Mesen2 build does NOT pass them — see the ADR 0030 campaign audit). // @@ -4173,6 +4174,13 @@ impl Ppu { /// scanlines (0-239) when rendering is enabled. fn tick_oam_bus(&mut self) { let cycle = self.dot; + // v2.3.0 (perf) — take the dot-0 early-out BEFORE deriving the sprite + // height and y-test reference; both were computed unconditionally and + // then discarded on this dot. Byte-identical: neither value is observable + // on the path that returns here. + if cycle == 0 { + return; + } let sprite_height: i16 = if self.ctrl.contains(PpuCtrl::SPRITE_SIZE_16) { 16 } else { @@ -4181,10 +4189,6 @@ impl Ppu { // Y-test reference: the scanline being evaluated (sprites render on // scanline+1). let scan = self.scanline; - - if cycle == 0 { - return; - } if cycle < 65 { // Secondary-OAM clear (cycles 1-64): the bus carries $FF and the // parallel secondary OAM is filled with $FF, 1 byte per 2 dots. @@ -4307,6 +4311,14 @@ impl Ppu { } } + // v2.3.0 (perf) — called once per ELIGIBLE dot on the fast dot path (visible + // dots 1..=256 with rendering enabled: up to 61,440/frame, not all 89,342 — + // idle lines and rendering-disabled paths bypass it entirely); + // `perf annotate` showed its own prologue/epilogue (`push`/`ret`) as the two + // hottest instructions in the body, i.e. pure call overhead LLVM had declined + // to remove. `inline` lets it be folded into the dot loop. Byte-identical (an + // inlining hint changes no behavior); adopted only if it clears the >3% bar. + #[inline] pub(crate) fn tick_sprite_eval_per_dot(&mut self) { // Y-test reference line for sprite evaluation. Per nesdev // "PPU OAM" (Byte 0): "The first scanline that the sprite is diff --git a/crates/rustynes-test-harness/tests/accuracycoin.rs b/crates/rustynes-test-harness/tests/accuracycoin.rs index 6c8d7af0..e994e895 100644 --- a/crates/rustynes-test-harness/tests/accuracycoin.rs +++ b/crates/rustynes-test-harness/tests/accuracycoin.rs @@ -58,6 +58,17 @@ use rustynes_test_harness::accuracy_coin_catalog; /// measured `75.93%` via framebuffer measures `64.03%` via RAM. const MIN_PASS_RATE: f64 = 0.60; +/// v2.3.0 "Datum II" — the exact number of AccuracyCoin tests the shipped +/// headless build passes, held since v2.0.3 promoted the 2-cycle-ALE / +/// delayed-`CopyV` PPU model (ADR 0030). +/// +/// Asserted alongside "zero failing" so that a battery which *under-executes* +/// (early bail, skipped suite, decoder that stops assigning cells) fails as +/// loudly as one that regresses — an empty failing list is not by itself +/// evidence of success. Re-bless this together with `docs/STATUS.md` if an +/// upstream ROM update changes the catalog. +const EXPECTED_PASS_COUNT: u32 = 141; + #[test] #[allow(clippy::too_many_lines)] fn accuracycoin_pass_rate_meets_floor() { @@ -333,4 +344,46 @@ fn accuracycoin_pass_rate_meets_floor() { summary.pass_with_code, summary.assigned(), ); + + // v2.3.0 "Datum II" regression guard: beyond the coarse honesty floor above, + // the shipped headless build has held a FULL 141/141 (zero failing tests) + // since v2.0.3, when the promoted 2-cycle-ALE / delayed-`CopyV` PPU model + // closed the two hybrid-address tests ("ALE + Read" $0491, "Hybrid Addresses" + // $0492) under the `PPU Misc.` suite (ADR 0030). The 60% floor is far too + // coarse to catch a single-test regression — e.g. neutralizing `COPY_V_DELAY` + // drops exactly the Hybrid Addresses test to 140/141 (99.29%), which still + // clears 60% silently (verified during the v2.3.0 investigation). Pin the + // exact state instead: zero failing tests. The two hybrid-address tests are + // the usual canaries for a `COPY_V_DELAY` / octal-latch regression. This is + // the CI-runnable, in-repo (MIT AccuracyCoin ROM) guard for that behavior. + // If an intentional, reviewed accuracy change moves the count, update this + // assertion in the same change (docs-as-spec) and re-bless. + assert!( + failing.is_empty(), + "AccuracyCoin regressed from the shipped 141/141: {} failing test(s) (listed above). \ + The two hybrid-address tests under `PPU Misc.` are the usual canaries for a \ + COPY_V_DELAY / octal-latch regression — see ADR 0030. If this is an intentional, \ + reviewed accuracy change, update this guard in the same commit.", + failing.len(), + ); + + // ...and pin the POSITIVE count too. `failing.is_empty()` alone only proves + // nothing reported Fail/Unknown — a battery that silently ran fewer tests + // (an early bail, a skipped suite, a decoder that stopped assigning cells) + // produces an empty failing list and would slip through, which is exactly the + // "absent looks like success" shape this guard exists to prevent. Assert the + // exact expected number of passes so under-execution fails as loudly as + // regression. + let passed = summary.pass + summary.pass_with_code; + assert_eq!( + passed, + EXPECTED_PASS_COUNT, + "AccuracyCoin passed {passed} tests, expected exactly {EXPECTED_PASS_COUNT} \ + ({} pass + {} pass_with_code of {} assigned). A LOWER count with an empty failing \ + list means the battery under-executed rather than regressed; a HIGHER count means \ + the upstream ROM grew and this guard plus docs/STATUS.md must be re-blessed together.", + summary.pass, + summary.pass_with_code, + summary.assigned(), + ); } diff --git a/docs/STATUS.md b/docs/STATUS.md index 698e0eec..cbf5e7d2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,12 +1,21 @@ # RustyNES — Project Status Matrix -> **Current release: v2.2.9** (2026-08-04) — **"Studio II"**, a frontend -> quality-of-life release (4th of the v2.2.6 → v2.3.0 NESdev-remediation line): -> TAStudio piano-roll edits now drive the emulator, `.bk2` movies play back -> honoring their `LogKey` column order, and tool windows can **detach into real -> OS windows** (fixing the Windows-10 trapped-window report). Frontend-only, so -> the deterministic core is untouched (**AccuracyCoin 141/141**, nestest 0-diff); -> the multi-window behavior awaits an on-device check. Built on +> **Current release: v2.3.0** (2026-08-05) — **"Datum II"**, the capstone closing +> the v2.2.6 → v2.3.0 NESdev-remediation line. Tool panels now open as **real OS +> windows** (v2.2.9's affordance only *embedded* them, so the Windows-10 +> trapped-window report is now genuinely fixed) and every tool window is +> detachable. Profiling that work found a **frame-pacing defect** predating it — +> the render path held the emulator lock across the blocking swapchain wait and +> present, stalling frame production whenever a debugger panel was open — now +> split so the lock covers only the UI build. The PPU's per-dot helpers gained +> **−5.13% / −3.51%** frame cost, byte-identically (`v2.3.0 P1`). Both remaining +> forum-reported accuracy items (SMB left edge, Rad Racer hybrid-address) were +> investigated and found **already correct**. **AccuracyCoin 141/141**, nestest +> 0-diff — now enforced as an *exact* count rather than a 60% floor. Built on +> **v2.2.9** (2026-08-04) — **"Studio II"**, a frontend quality-of-life release: +> TAStudio piano-roll edits drive the emulator and `.bk2` movies play back +> honoring their `LogKey` column order; its tool-window detach **embedded** the +> panel rather than opening a separate OS window (resolved in v2.3.0). Built on > **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 + @@ -267,8 +276,9 @@ > validated byte-for-byte equal by `simd_equals_scalar_byte_identical` over the full `0..512` > domain; a memory-bound LUT gather, so the SIMD path is within noise of scalar and **scalar stays > the default**, documented honestly) plus a **wasm size / startup pass** (3.99 MiB gzip). -> **Shipped default-OFF** (recommended for promotion after maintainer review + a clean-host -> Criterion confirmation). See `CHANGELOG.md` `[2.1.8]` + `.github/release-notes/v2.1.8.md`. +> **Shipped default-OFF in v2.1.8; PROMOTED TO DEFAULT in v2.2.3** once the maintainer-review + +> clean-host Criterion confirmation it named were met (−11.3% frame time, differential-tested +> byte-identical). See `CHANGELOG.md` `[2.1.8]` / `[2.2.3]` + `.github/release-notes/v2.1.8.md`. > > **Earlier in the Fathom line: v2.1.7 "Fathom" ("Stepping")** (2026-07-12) — a > **hardware-revisions & DMA-frontier** cut, every knob **opt-in and default-off** so the diff --git a/docs/adr/0030-accuracycoin-ale-read-hybrid-addresses-octal-latch.md b/docs/adr/0030-accuracycoin-ale-read-hybrid-addresses-octal-latch.md index e9710afd..db215ca1 100644 --- a/docs/adr/0030-accuracycoin-ale-read-hybrid-addresses-octal-latch.md +++ b/docs/adr/0030-accuracycoin-ale-read-hybrid-addresses-octal-latch.md @@ -223,8 +223,12 @@ The dedicated campaign this ADR called for landed on branch build does NOT pass these two tests** (both result bytes read `0x0A` = corruption not reproduced), so "Option 2 = proven-correct Mesen2 recipe" was false. The correct oracle is **TriCNES** (`TriCNES/Emulator.cs`, MIT, commit `9199870` — the AccuracyCoin - author's own emulator), which models the multiplexed AD/A bus + octal latch at transistor - level and does drive `$2F19` / `$0FFF`. The campaign audit + author's own emulator), which models the multiplexed AD/A bus + octal latch at the + pin/sub-cycle level and does drive `$2F19` / `$0FFF`. (Corrected in v2.3.0: earlier + revisions of this ADR called TriCNES "transistor level". It is not — it is a + cycle-accurate C# emulator with a detailed sub-cycle state machine. "Transistor + level" properly describes die-derived simulations such as `Visual2C02` / + `phantom2c02`.) The campaign audit (`docs/audit/v2.0.2-octal-latch-campaign-2026-07-08.md`) records the decisive finding. 2. **A whole-dot port of TriCNES's octal latch suffices** — the full 2-cycle-ALE fetch @@ -438,3 +442,37 @@ defaults (`0`/`false`) — the state a save taken at a fetch boundary always hol save-states still load (per the ADR 0028 spirit; the rendered output for the two titles above is the only user-visible change, an intentional accuracy improvement, not a format break). The netplay determinism battery (all 16 tests) passes with the v5 tail. + +## Update — 2026-08-05 (v2.3.0 "Datum II" — hybrid-address model verified correct; the suspected Rad Racer artifact does NOT reproduce) + +v2.2.6 disclosed (this ADR; `NOTICE`; `docs/originality-and-provenance.md` §4) that the octal-latch +/ hybrid-address *timing* had been calibrated to TriCNES rather than derived independently, and +flagged a suspected TriCNES-specific artifact — mid-render `$2006` writes mis-rendering, e.g. Rad +Racer's road/horizon split — for a v2.3.0 rework. v2.3.0 investigated it under systematic-debugging +discipline (reproduce before fixing) and reached an evidence-based conclusion: **there is nothing +to rework — the shipped model is correct on both the authoritative oracle and the game.** + +- **AccuracyCoin (the authoritative in-repo oracle, MIT):** the shipped default (`COPY_V_DELAY = 4`, + the delayed-`CopyV` model promoted in v2.0.3) holds **141/141** — the two hybrid-address tests + ("ALE + Read" `$0491`, "Hybrid Addresses" `$0492`) under `PPU Misc.` pass. An A/B probe + neutralizing the delay (`COPY_V_DELAY = 0`) drops **exactly** the Hybrid Addresses test to + **140/141** (99.29%), confirming the delay is load-bearing and the current value accuracy-correct. + Mesen2 is *not* an authority here — per this ADR it reads `0x0A` (fails these tests); TriCNES and + RustyNES pass them. +- **Rad Racer (headless render):** the road/horizon renders cleanly at `COPY_V_DELAY = 4`; the A/B + `COPY_V_DELAY = 0` render is *more* distorted (wider/flatter road, mislocated horizon) on the same + game state (the delay only affects the PPU `v` register mid-render, not game logic). The shipped + default is therefore both the accuracy-correct and the visually-correct model. + +Root cause of the (now-stale) concern: the artifact lived in the **pre-v2.0.3 `+1 coarse-X` +reconstruction approximation**, which the promoted 2-cycle-ALE / delayed-`CopyV` model *superseded* +(v2.0.3 update above). The forum report predates that promotion. + +Disposition: **no code change.** The model is confirmed documentation/oracle-derived — it matches +the NESdev-documented delayed-`CopyV`-during-render timing (`PPU_rendering.xhtml`) and the +AccuracyCoin ROM's expectations; TriCNES (MIT, vendored) is retained as the original +cross-reference. Provenance updated in `NOTICE` and `docs/originality-and-provenance.md` §4 (from +"TriCNES-calibrated, being reworked" to "verified correct, oracle/doc-derived"). To lock it, the +AccuracyCoin gate was tightened from a coarse 60% floor to an **exact 141/141** (zero failing tests) +in `crates/rustynes-test-harness/tests/accuracycoin.rs`, so a `COPY_V_DELAY` / octal-latch +regression now fails CI — closing the gap that let the delay=0 probe (140/141) clear the old floor. diff --git a/docs/originality-and-provenance.md b/docs/originality-and-provenance.md index 1dfaf602..228c419a 100644 --- a/docs/originality-and-provenance.md +++ b/docs/originality-and-provenance.md @@ -131,11 +131,57 @@ this project previously mislabeled the second as the first. | puNES | GPL-2.0-or-later | Derivation (§1) **and** oracle | | FCEUX | GPL-2.0-or-later | Derivation (§1) **and** oracle | | Nestopia UE | GPL-2.0-or-later | Derivation (§1, FME-7/5B) **and** oracle | -| GeraNES | GPL-3.0-only | Oracle / cross-check only (no code derived) | +| GeraNES | GPL-3.0-only | Reference / cross-check: source consulted to confirm publicly-documented behavior; no code copied (see note) | | higan | GPL-3.0-or-later | Scheduler-structure reference / oracle | | ares | BSD-2-Clause / Apache-2.0 | Palette-integration reference (§1) / oracle | | TriCNES | MIT | Incorporated (§5) **and** timing-calibration reference (§4) | +**GeraNES specifically (honest note).** Some in-source comments previously cited +GeraNES *source* files, functions, and even quoted a line (e.g. a mapper +bus-conflict `data &= readPrg(addr)` in `Mapper093.h`) — which means GeraNES's +source was consulted as a cross-reference during development, not purely +black-box observation. Every behavior so cross-referenced is independently +documented: the nesdev wiki for the mapper bus-conflict / address-decode masks +(the affected comments already cite `INES_Mapper_089/093`), and RetroArch's +public `#pragma parameter` specification for the shader-parameter UI. The +RustyNES implementations are independent Rust (`value & self.read_prg(addr)`, not +the C++ line). On the evidence below, the maintainers' **assessment** is that only +unprotectable, publicly-documented *behavior* was relied on rather than GeraNES's +copyrightable *expression*, and therefore that no GPL-3.0-only code is +incorporated and the project's GPL-3.0-or-later grant is unconstrained. That is a +position supported by the record, **not a certification** — see the caveat at the +end of this section. The comments have been reworded to state the relationship +accurately and to drop the now-removed source-path citations (the local +reference-emulator tree was deleted from disk; see the reference firewall in +`AGENTS.md`). + +This was verified two-sided at the maintainer's direction: the GeraNES source +(`gracioni/GeraNES`) for `Mapper089.h` / `Mapper093.h` was compared against +RustyNES's `m089_sunsoft2.rs` / `m093_sunsoft3r.rs` *and* against the nesdev wiki +(`INES_Mapper_089` `[CPPP MCCC]`, `INES_Mapper_093` `[.PPP ...E]`, both marked +"BUS CONFLICTS"). The operations that coincide with GeraNES — the bus-conflict +`written & rom_byte` mask and the bit-field extractions (`(v >> 4) & 0x07`, etc.) +— are exactly the nesdev-documented register layouts, i.e. the single correct +expression of the documented hardware (merger doctrine). No arbitrary, +non-hardware-dictated choice coincides (identifiers, decomposition, and idiom are +independent Rust), which is the signature distinguishing documented-fact +convergence from copying. + +**Status, not a verdict.** The paragraphs above record what was examined and what +was found; they are deliberately not a clearance. This project's provenance rules +(`docs/ai-emulator-provenance-guardrails.md`) forbid self-certifying license +cleanliness, and for good reason: the original provenance failure was caught by an +outside NESdev reviewer, not by the project's own tooling or by the AI assistance +that produced the code. Two specific residual uncertainties are worth naming. +First, whether a given coincidence is "the single correct expression of a +documented fact" is ultimately a legal judgement about merger and scenes-a-faire, +not something a code comparison settles. Second, the review covered the files the +in-source comments pointed at; it does not prove the absence of consultation +elsewhere. This assessment is therefore offered **for human and expert review**, +and a qualified reviewer who disagrees should be treated as authoritative over it. +The remedy in that case is the same one v2.2.9 already applied once — relicense +and attribute, never quietly reword. + Because the license of the derived-from GPL code governs regardless of how any one file was used, the whole project is GPL-3.0-or-later; the oracle/derivation distinction affects attribution, not the license. @@ -169,13 +215,21 @@ earlier document did. - **Measure-first performance with published rejections.** `docs/performance.md` records optimizations that were measured and *rejected* with their numbers — an unusual discipline that is genuinely the project's own. -- **The 2-cycle-ALE octal-latch PPU model and its honest caveat (ADR 0030).** The - physical octal-latch model was an independent modeling choice, but — as already - disclosed in v2.2.6 and retained here — its *timing* was calibrated to TriCNES - (MIT) rather than derived from an independent measurement, which is why RustyNES - reproduced TriCNES's Rad Racer hybrid-address artifact. The v2.3.0 "Datum II" - work reworks this to be documentation-derived. TriCNES is MIT-licensed, so this - is an attribution/fidelity matter, not a GPL one. +- **The 2-cycle-ALE octal-latch PPU model, verified correct (ADR 0030).** The + physical octal-latch model was an independent modeling choice; v2.2.6 disclosed + that its *timing* had been calibrated to TriCNES (MIT) rather than derived + independently, and flagged a suspected TriCNES-specific Rad Racer hybrid-address + artifact for a v2.3.0 rework. **v2.3.0 "Datum II" investigated it and found the + suspected artifact does not reproduce in the shipped build:** the model passes + the authoritative in-repo AccuracyCoin "Hybrid Addresses" / "ALE + Read" tests + (141/141 — disabling the delayed-`CopyV` drops exactly that test to 140/141), + and Rad Racer's road/horizon renders cleanly. The artifact lived in the + pre-v2.0.3 "+1 coarse-X" approximation, which the promoted 2-cycle-ALE / + delayed-`CopyV` model *superseded*. The current behavior is therefore + documentation/oracle-derived — it matches the NESdev-documented + delayed-`CopyV`-during-render timing and is pinned by the MIT AccuracyCoin ROM + plus an exact-141/141 CI gate. TriCNES (MIT) is retained as the original + cross-reference; this is an attribution/fidelity matter, not a GPL one. --- diff --git a/docs/performance.md b/docs/performance.md index cbc8c6fc..90ab4721 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -8,9 +8,20 @@ Set quantitative performance targets, identify expected hot paths, and lay out t ## Targets -| Metric | Target | Stretch | +> **These are DESIGN-PHASE targets, written before the cycle-accurate core +> existed — they are aspirations, not gates.** The frame-cost row in particular +> was never met and is knowingly accepted: the implemented core measures +> **~3.9 ms** (`nes_run_frame_nestest_fast`) / **~2.5 ms** (`flowing_palette`) +> on a 2020 desktop (see "Measured" below and the v2.0.1 table). The gate that +> actually runs in CI is the **relative, same-runner regression check** (§CI +> gate), not this table. Do not treat ≤ 2 ms as a goal to optimize toward by +> trading away accuracy — the dominant costs are work the accuracy model +> requires (APU BLEP synthesis in `cpu_clock`; the per-dot loop in `Ppu::tick`), +> and the obvious levers were measured and **rejected** (v2.2.3 P3/P4 below). + +| Metric | Target (aspirational) | Stretch | |--------|--------|---------| -| Frame cost (NTSC, headless core) | ≤ 2 ms on 2018-era x86_64 (Skylake) | ≤ 1 ms | +| Frame cost (NTSC, headless core) | ≤ 2 ms on 2018-era x86_64 (Skylake) — **not met; ~3.9 ms accepted** | ≤ 1 ms | | Frame cost (full frontend) | ≤ 5 ms | ≤ 3 ms | | Cold-start to first frame | ≤ 100 ms | ≤ 50 ms | | Save state size (uncompressed) | ≤ 64 KB typical | — | @@ -560,6 +571,44 @@ for a byte-identical escape hatch is not justified. had zero callers outside the core and its tests, so no shipped configuration of any frontend could enable it. +### v2.3.0 P1 — per-dot sprite-eval / OAM-bus call cost (decision: ADOPTED) + +The v2.3.0 frontend-stutter investigation re-profiled the core on a quiet machine +(2% outliers, vs 39% on the first noisy attempt — a reminder that a contended +machine invalidates the baseline before it invalidates the conclusion). Excluding +~17% of samples belonging to criterion's own harness (rayon plumbing, `libm exp`, +its sorts), self-time split **PPU ~53% / CPU+bus ~39%**, with two per-dot helpers +outside every previously-examined lever: `tick_sprite_eval_per_dot` **4.45%** and +`tick_oam_bus` **3.22%**. + +`perf annotate` (the same instrument that redirected P4) found the actual cost was +not the state machines themselves: + +- In `tick_sprite_eval_per_dot` the two hottest instructions in the whole body were + its own `push %rax` (5.35%) and `ret` (5.41%) — **pure call overhead**. It is + invoked once per *eligible* dot from the fast dot path — visible dots 1..=256 + with rendering enabled, up to 61,440/frame (not all 89,342: idle lines and + rendering-disabled paths bypass it) — and LLVM had + declined to inline it. +- `tick_oam_bus` derived `sprite_height` (a `PpuCtrl` test) and the y-test + reference `scan` **before** its dot-0 early-out, computing and discarding both. + +Two byte-identical changes: add `#[inline]` to `tick_sprite_eval_per_dot`, and +hoist the `cycle == 0` early-out above the two derivations in `tick_oam_bus`. + +| Workload | before | after | change (95% CI, p) | +|---|---|---|---| +| `nes_run_frame_nestest_fast` | 3.8987 ms | **3.7830 ms** | **−5.13%** (−5.60…−4.60, p = 0.00) | +| `nes_run_frame_flowing_palette_fast` | 2.7314 ms | **2.6354 ms** | **−3.51%** (−3.93…−3.10, p = 0.00) | + +Both clear the **>3%** adoption bar on both workloads. Byte-identity verified: +AccuracyCoin **141/141** (the exact-count gate), nestest golden **0-diff**, and +`rustynes-ppu` unit tests 91/91. **Adopted.** + +Note what this does *not* change: the core is still ~3.8 ms, not the aspirational +≤ 2 ms (see §Targets). The remaining bulk is work the accuracy model requires, and +the levers below were already measured and rejected. + ### v2.2.3 P4 — every-cycle bus cost `cpu_clock` (decision: no change adopted) `<LockstepBus as Bus>::cpu_clock` is the second-hottest function at **22.43%** diff --git a/docs/scheduler.md b/docs/scheduler.md index 16331237..dabdf3cc 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -154,7 +154,13 @@ This guarantees that save/load round-trips and a re-played input sequence produc ## Performance targets -- Frame cost (single-thread, no rendering): ≤ 2 ms on a 2018-era laptop x86_64 (Skylake-era). +> These are the original **design-phase aspirations**, not gates. The frame-cost +> figure was not met and is knowingly accepted — the implemented cycle-accurate +> core measures **~3.9 ms** (nestest) / **~2.5 ms** (flowing palette), ~23% of the +> 16.639 ms NTSC budget. See `docs/performance.md` §Targets for the measured +> numbers and why the main optimization levers were measured and rejected. + +- Frame cost (single-thread, headless core — no frontend, no present): ≤ 2 ms on a 2018-era laptop x86_64 (Skylake-era) — **aspirational; ~3.8 ms measured and accepted** (`nes_run_frame_nestest`, which renders; the render-light `flowing_palette` workload measures ~2.6 ms). - Frame cost including wgpu present + cpal callback: ≤ 5 ms (well under the 16.67 ms budget for 60 fps NTSC). - Audio callback: lock-free SPSC ring buffer; never block the audio thread. diff --git a/to-dos/plans/v2.0.2-harbor-plan.md b/to-dos/plans/v2.0.2-harbor-plan.md index d1a45c32..bf99fc53 100644 --- a/to-dos/plans/v2.0.2-harbor-plan.md +++ b/to-dos/plans/v2.0.2-harbor-plan.md @@ -42,7 +42,8 @@ to the v2.0.1 plan: recipe" premise was false. The correct definitive oracle is **TriCNES** (`ref-proj/TriCNES/Emulator.cs`, MIT, commit `9199870` — the **AccuracyCoin author's own emulator**), which models the multiplexed AD/A bus + octal latch at - transistor level and does drive `$2F19` / `$0FFF`. + the pin/sub-cycle level and does drive `$2F19` / `$0FFF`. (Corrected in v2.3.0: + TriCNES is a cycle-accurate C# emulator, not a transistor-level one.) 2. **A whole-dot port suffices.** The full 2-cycle-ALE fetch refactor was **not** required. Porting TriCNES's octal latch at RustyNES's whole-dot cadence, behind the pre-existing `mc-ppu-bus-addr-hybrid` flag, reaches 141/141 flag-on.