From 472929bac6a35d06f932d1b29dcfb7bbad49d6bb Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 19:05:20 -0400 Subject: [PATCH 001/104] docs(plan): v3.2.0 scope decisions -- Datach in, FFE 6 and 17 added Recorded at the start of the release, before any code: - 157 (Datach) is in scope with a barcode-reader input (maintainer, 2026-10-08). - FFE 6 and 17 join item 1. A recursive header index of the dump library (24,763 dumps) found none of the 15 listed families implemented, at least one dump for each, and FFE 6 (153 dumps) and 17 (51) unimplemented beside 8's two. Same copier hardware, same wiki pages. - The plan's "two staged Doraemon hacks refused as mapper 8" do not exist: no Doraemon dump carries mapper 8. The gate is restated against the library's actual mapper-8 dumps. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/plans/v3.2.0-plan.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index e02f8ba5..3d6ed3a5 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -13,7 +13,7 @@ until the hardware release shipped.) The codename is asked at the cut. | # | Item | Gate | | --- | --- | --- | -| 1 | The missing families with real titles, each first searched for in `~/Dropbox/ROMs/` (D21): 8 (FFE), 158 (Tengen 800037), 207 (Taito X1-005 variant), 116, 114/182/215, 197, 165, 172/173, 230, 235, 252. 157 (Datach) is open: it needs a barcode reader | Per family: written from its NESdev page; a register-decode unit test; a boot snapshot from a real dump, or a synthetic CC0 fixture where none exists; a tier set honestly (ADR 0011); `docs/mappers.md` updated. The two staged *Doraemon* hacks that are refused as "mapper 8" today boot | +| 1 | The missing families with real titles, each first searched for in `~/Dropbox/ROMs/` (D21): 8 (FFE), 158 (Tengen 800037), 207 (Taito X1-005 variant), 116, 114/182/215, 197, 165, 172/173, 230, 235, 252; 157 (Datach) with a barcode-reader input (in scope, maintainer 2026-10-08); and FFE 6 and 17 (added 2026-10-08, see below) | Per family: written from its NESdev page; a register-decode unit test; a boot snapshot from a real dump, or a synthetic CC0 fixture where none exists; a tier set honestly (ADR 0011); `docs/mappers.md` updated. The mapper-8 dump in the library boots (see below on the *Doraemon* line) | | 2 | ACC-08 / ACC-09: KNOWN_BLANK triage (`T-KNOWN-BLANK`), starting with *Chu Liu Xiang* (176.2; the mirroring power-on hypothesis, a CPU trace first), the Sansuu 1-3 Nen titles (m185) and *Gradius II (VC)* | Each dump leaves the list by a red-first fix with an epoch rise, or stays with a recorded cause. The ratchet holds in both directions | | 3 | MAP-01 tier promotions under D23 (`T-CURATED-EVIDENCE`): 47, 121, 191, 290, 154, 243 | Each has a byte-identity boot snapshot in `external_extended.rs`, and a record naming the title, hash, frames and screenshot; the honesty gate's counts are updated | | 4 | The dump-corpus sweep (D21, `T-DUMP-CORPUS`): every implemented family with a Dropbox dump and no recorded boot | A boot snapshot per family, or a recorded refusal with a reason. Nothing committed but snapshots, screenshots and hashes | @@ -27,6 +27,19 @@ until the hardware release shipped.) The codename is asked at the cut. | 7 | The next ~10 by coverage per effort: the 206 family (Namco 108, a subset of MMC3 decode), 66, 11, 79, 9/10, 118/119, 71/232, 34, and the trivial discretes (13, 87, 180, 185, 94, 140) | Same as row 6, per family | | 8 | FB-10 paddle, FB-8 Four Score, FB-6 cheats (an oracle-gated rung: apply a code on both sides and compare the bus) | Each with an exporter stimulus flag and a gate | +## Scope decisions (2026-10-08, at the start of the release) + +- **157 (Datach) is in scope**, with a barcode-reader input device (maintainer). +- **FFE 6 and 17 are added to item 1.** A recursive index of the library + (24,763 dumps read by header) found none of the 15 listed families + implemented, at least one dump for each, and also that FFE 6 (153 dumps) and + 17 (51) are not implemented either, against 8's two. The three are the same + copier hardware on the same wiki pages, so they are done together. +- **The "two staged *Doraemon* hacks refused as mapper 8" were not found.** No + *Doraemon* dump in the library or in `tests/roms/external/` carries mapper 8; + the library's mapper-8 dumps are *Dragon Ball - Shen Long no Nazo (J) + [hFFE]* and an AVE title. The gate is restated against those. + ## Sprints | Sprint | Deliverable | Gate | Status | From 77f96e31b4e814b2e14c0886cee1b20daa730958 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 19:21:59 -0400 Subject: [PATCH 002/104] docs(plan): correct the Doraemon note; restate item 4's gap Corrects 472929ba, which said the two staged Doraemon hacks refused as mapper 8 do not exist. They do: tests/roms/external/mapper-072-Jaleco72/ "Doraemon World 3 by Kiku (Doraemon Hack)", .nes and .zip, read as mapper 8 only after the dirty-tail ("DiskDude!") masking rule, which is why a search by directory name and raw header missed them. external_coverage.rs lists both in UNSUPPORTED. The plan's original gate stands. Also: "no recorded boot" means "no staged dump", since external_coverage snapshots every staged dump. Measured against mapper_tier itself (191 supported; an earlier text parse of tier.rs read the test module's constants and was wrong), item 4's gap is 154, 243, 268 and 290, all of which item 3 already names. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/plans/v3.2.0-plan.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index 3d6ed3a5..18cb1e53 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -13,7 +13,7 @@ until the hardware release shipped.) The codename is asked at the cut. | # | Item | Gate | | --- | --- | --- | -| 1 | The missing families with real titles, each first searched for in `~/Dropbox/ROMs/` (D21): 8 (FFE), 158 (Tengen 800037), 207 (Taito X1-005 variant), 116, 114/182/215, 197, 165, 172/173, 230, 235, 252; 157 (Datach) with a barcode-reader input (in scope, maintainer 2026-10-08); and FFE 6 and 17 (added 2026-10-08, see below) | Per family: written from its NESdev page; a register-decode unit test; a boot snapshot from a real dump, or a synthetic CC0 fixture where none exists; a tier set honestly (ADR 0011); `docs/mappers.md` updated. The mapper-8 dump in the library boots (see below on the *Doraemon* line) | +| 1 | The missing families with real titles, each first searched for in `~/Dropbox/ROMs/` (D21): 8 (FFE), 158 (Tengen 800037), 207 (Taito X1-005 variant), 116, 114/182/215, 197, 165, 172/173, 230, 235, 252; 157 (Datach) with a barcode-reader input (in scope, maintainer 2026-10-08); and FFE 6 and 17 (added 2026-10-08, see below) | Per family: written from its NESdev page; a register-decode unit test; a boot snapshot from a real dump, or a synthetic CC0 fixture where none exists; a tier set honestly (ADR 0011); `docs/mappers.md` updated. The two staged *Doraemon World 3* hacks that are refused as "mapper 8" today (`external_coverage.rs` `UNSUPPORTED`) boot, as does the library's mapper-8 dump | | 2 | ACC-08 / ACC-09: KNOWN_BLANK triage (`T-KNOWN-BLANK`), starting with *Chu Liu Xiang* (176.2; the mirroring power-on hypothesis, a CPU trace first), the Sansuu 1-3 Nen titles (m185) and *Gradius II (VC)* | Each dump leaves the list by a red-first fix with an epoch rise, or stays with a recorded cause. The ratchet holds in both directions | | 3 | MAP-01 tier promotions under D23 (`T-CURATED-EVIDENCE`): 47, 121, 191, 290, 154, 243 | Each has a byte-identity boot snapshot in `external_extended.rs`, and a record naming the title, hash, frames and screenshot; the honesty gate's counts are updated | | 4 | The dump-corpus sweep (D21, `T-DUMP-CORPUS`): every implemented family with a Dropbox dump and no recorded boot | A boot snapshot per family, or a recorded refusal with a reason. Nothing committed but snapshots, screenshots and hashes | @@ -35,10 +35,18 @@ until the hardware release shipped.) The codename is asked at the cut. implemented, at least one dump for each, and also that FFE 6 (153 dumps) and 17 (51) are not implemented either, against 8's two. The three are the same copier hardware on the same wiki pages, so they are done together. -- **The "two staged *Doraemon* hacks refused as mapper 8" were not found.** No - *Doraemon* dump in the library or in `tests/roms/external/` carries mapper 8; - the library's mapper-8 dumps are *Dragon Ball - Shen Long no Nazo (J) - [hFFE]* and an AVE title. The gate is restated against those. +- **The "two staged *Doraemon* hacks refused as mapper 8" exist** (corrected + the same day). An earlier note here said they did not: a search by directory + name and header could not find them, because they sit in + `mapper-072-Jaleco72/` and read as mapper 8 only after the dirty-tail + ("DiskDude!") masking rule. `external_coverage.rs` lists both in + `UNSUPPORTED` for exactly that reason. The gate stands as written, plus the + library's own mapper-8 dump (*Dragon Ball - Shen Long no Nazo (J) [hFFE]*). +- **"No recorded boot" means "no staged dump".** `external_coverage.rs` + discovers and snapshots every staged dump, so item 4's gap is the supported + families with a library dump and nothing staged: 154, 243, 268 and 290 + (measured against `mapper_tier` itself, 191 supported). All four are also in + item 3, which absorbs them. ## Sprints From 7b1c2414fcb0c7ea2964f50019e7da405036f44e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 19:25:48 -0400 Subject: [PATCH 003/104] feat(mappers): add mapper 172/173 (TXC JV001 and 05-00002-010 boards) Two copy-protected TXC / Idea-Tek boards, written from the vendored NESdev pages INES_Mapper_172 and INES_Mapper_173 plus the chip pages they link (INES_Mapper_136 for the JV001 register file, TXC_05_00002_010_pinout). No reference-emulator source was read; m132/m136 are not reused, so every bit of the new module traces to one sentence of documentation. Both chips hold a register that a $4100 write loads from an input latch (optionally inverted) or increments, that $4100 reads back, and that any $8000-$FFFF write copies into an output latch driving CHR address lines (the written value is ignored). A board that latched the $8000 data would fail the games' read-back check: that is the copy protection. - 172 (Super Mega P-4070, JV001): six-bit Input/Register/Output, Mode and Invert, mask $E103, CPU D0-D5 wired to the chip in reverse order. The model keeps chip order internally and reverses at the bus. Mode 0 loads Input with bits 0-3 inverted under Invert; Mode 1 increments bits 0-3 and keeps 4-5; reads invert bits 4-5 under Invert and drive D0-D5 only (cpu_read_driven_mask = $3F). $8000 latches Output (CHR bank = Output & 3) and sets mirroring: horizontal when Invert is clear, vertical when set. The header's mirroring holds until the first such write. PRG fixed 32 KiB. - 173 (Idea-Tek ET-xx, 05-00002-010): P/R/Output 3 bits, S/C/V 1 bit; reads [xxxx SRRR] with S XOR V under mask $E100 and D4-D7 open bus; CHR bank = (Output & 1) | (~V << 1) with V read live; hard-wired mirroring. Verification - Red first: the module's 11 parse-driven tests failed with UnsupportedMapper(172/173) before the dispatch arms existed. 13 unit tests now pass (decode masks, bit reversal, invert/increment, CHR, mirroring, open-bus mask, state round trip, geometry). - 1991 Du Ma Racing (Ch) [!] (172) boots to its title and, with START, to the race-betting screen; Xiao Ma Li (Ch) [!] (173) boots to its title and, with START, to the slot-machine game (the page says it blanks the PPU when the protection read fails). Byte-identity boot snapshots in external_extended.rs; external_coverage snapshots blessed for both. D23 rows in docs/compatibility.md; screenshots committed. - Mutation: dropping the 172 input bit reversal failed two unit tests ("reversed in, reversed out": left 36, right 9) and the Du Ma Racing boot snapshot (fb 03c22bdd970c7e34 -> 89ee4c476c97a325). Taking 173's CHR A14 from V instead of ~V failed "Output 0, ~V = 1" (left 0, right 2); loading PPP without the V inversion failed "~PPP" (left 2, right 5). Neither 173 mutant moves the Xiao Ma Li snapshot: its 8 KiB CHR wraps every bank, and its protection check runs after START, past the 600-frame idle. Tier: Curated for both (exact masks plus a booting D23 dump). Not verified / not modelled - Xiao Ma Li's /PGM pin: "setting Invert just disables CHR-ROM", but the page does not say what the PPU then reads, so an 8 KiB CHR image wraps. - The TXC Mahjong Block (CRC32 0ACFC3CD), commonly headered 173, is a mapper 132 board per both pages; run as 173 it draws garbage, as the page predicts. It is not staged and not supported as 173. - docs/mappers.md's family totals are left to the integrator. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 17 + crates/rustynes-mappers/src/m172_m173_txc.rs | 789 ++++++++++++++++++ crates/rustynes-mappers/src/tier.rs | 9 +- .../tests/external_extended.rs | 23 + ...72_TXC_JV001_1991_Du_Ma_Racing_Ch_zip.snap | 13 + ...73_TXC_05_00002_010_Xiao_Ma_Li_Ch_zip.snap | 13 + ...nded__extended_m172_1991_du_ma_racing.snap | 12 + ...al_extended__extended_m173_xiao_ma_li.snap | 12 + docs/compatibility.md | 13 + docs/mappers.md | 14 + .../1991 Du Ma Racing (Ch) [!].png | Bin 0 -> 5455 bytes .../Xiao Ma Li (Ch) [!].png | Bin 0 -> 4699 bytes 12 files changed, 913 insertions(+), 2 deletions(-) create mode 100644 crates/rustynes-mappers/src/m172_m173_txc.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_172_TXC_JV001_1991_Du_Ma_Racing_Ch_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_173_TXC_05_00002_010_Xiao_Ma_Li_Ch_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m172_1991_du_ma_racing.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m173_xiao_ma_li.snap create mode 100644 screenshots/external/mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].png create mode 100644 screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index eefad19d..5de30af9 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -118,6 +118,7 @@ mod m151_konami_vs; mod m152_bandai152; mod m156_daou156; mod m163_nanjing; +mod m172_m173_txc; mod m176_bmc_fk23c; mod m177_hengedianzi; mod m179_hengedianzi; @@ -228,6 +229,7 @@ pub use m151_konami_vs::KonamiVs; pub use m152_bandai152::Bandai152; pub use m156_daou156::Daou156; pub use m163_nanjing::Nanjing163; +pub use m172_m173_txc::{Txc172, Txc173}; pub use m176_bmc_fk23c::new_m176; pub use m177_hengedianzi::Hengedianzi177; pub use m179_hengedianzi::Hengedianzi179; @@ -1567,6 +1569,21 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { new_m299(prg_rom, chr_rom, h.mirroring) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // --- v3.2.0: boards written from their NESdev pages (no + // reference-emulator source read). Tiers and evidence in `tier.rs` + // and `docs/mappers.md`. + // + // 172 / 173: TXC / Idea-Tek protection-chip boards + // (`m172_m173_txc.rs`). 172 controls its own mirroring; 173's is + // hard-wired, so the header's is passed for both as the power-on value. + 172 => Box::new( + Txc172::new(prg_rom, chr_rom, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), + 173 => Box::new( + Txc173::new(prg_rom, chr_rom, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/m172_m173_txc.rs b/crates/rustynes-mappers/src/m172_m173_txc.rs new file mode 100644 index 00000000..3f1323a1 --- /dev/null +++ b/crates/rustynes-mappers/src/m172_m173_txc.rs @@ -0,0 +1,789 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Mappers 172 and 173: two copy-protected TXC / Idea-Tek boards built around +//! a latch-adder-inverter ASIC (v3.2.0). +//! +//! Written from the vendored `NESdev` pages `INES_Mapper_172`, +//! `INES_Mapper_173`, and the two pages they link for the chip itself, +//! `INES_Mapper_136` (the JV001 register file mapper 172 shares) and +//! `TXC_05_00002_010_pinout` (the 24-pin chip on mapper 173). No +//! reference-emulator source was read. The project's mapper 132 +//! (`m132_txc_22211.rs`) and mapper 136 (`m136_sachen_3011.rs`) model +//! relatives of these chips, but neither is reused: this module states the two +//! register files exactly as their pages give them, so each bit can be checked +//! against one sentence of documentation. +//! +//! ## The protection chip +//! +//! Both chips hold a small **register** that the game can load from an +//! **input** latch (optionally inverted), increment in place, and read back +//! through `$4100`. A write anywhere in `$8000-$FFFF` then copies the register +//! into an **output** latch whose pins drive the CHR-ROM address lines. The +//! value written to `$8000` is ignored. A game proves the chip is present by +//! putting it through a sequence of loads, inversions and increments and then +//! comparing what `$4100` returns; a board that simply latched the `$8000` +//! data would fail that check, which is the copy protection. +//! +//! ### Mapper 172: JV001, mounted upside down (Super Mega P-4070) +//! +//! Five registers: Input (6 bits), Output (6 bits), Register (6 bits), Mode +//! (1 bit) and Invert (1 bit), at mask `$E103`. The board connects CPU D0-D5 to +//! the chip in **reverse order** ("the JV001 ASIC being mounted upside down", +//! `INES_Mapper_136`), so every value crossing the data bus is bit-reversed. +//! This model stores the chip's registers in the chip's own bit order and +//! reverses at the bus: +//! +//! | access | effect (chip order) | +//! |---|---| +//! | read `$4100-$4103` | Register, bits 4-5 inverted when Invert = 1, reversed onto D0-D5; D6-D7 open bus | +//! | write `$4100` | Mode 0: Register := Input, bits 0-3 inverted when Invert = 1. Mode 1: Register bits 0-3 += 1, bits 4-5 kept | +//! | write `$4101` | Invert := CPU D5 | +//! | write `$4102` | Input := CPU D0-D5, reversed | +//! | write `$4103` | Mode := CPU D5 | +//! | write `$8000-$FFFF` | Output := Register; mirroring := Horizontal if Invert = 0, Vertical if Invert = 1 | +//! +//! "Bits 0/1 of the 8 KiB CHR ROM bank number come from Output 5/4, or 0/1 +//! after taking the reverse bit order into account", so in chip order the +//! 8 KiB CHR bank is `Output & 3`. PRG is a fixed 32 KiB. Unlike the other +//! JV001 boards, mirroring is software-controlled, so the board reports +//! [`Mapper::has_hardwired_mirroring`] `false`. Until the first `$8000` write +//! the header's mirroring stands: the page says only what a write changes it to. +//! +//! ### Mapper 173: 05-00002-010 (Idea-Tek ET-xx) +//! +//! Six registers: "P", "R" and Output (three bits each); "S", inCrement and +//! inVert (one bit each), at the CPU's own bit order: +//! +//! | access | effect | +//! |---|---| +//! | read `$4100` (mask `$E100`) | `[xxxx SRRR]`: RRR, and S XOR V on D3; D4-D7 open bus | +//! | write `$4100` (mask `$E103`) | C set: RRR += 1. Else RRR := PPP, or ~PPP when V is set. S unchanged | +//! | write `$4101` | V := D0 | +//! | write `$4102` | S := D3, PPP := D0-D2 | +//! | write `$4103` | C := D0 | +//! | write `$8000-$FFFF` (mask `$8000`) | Output := the bottom two bits of RRR | +//! +//! "8 KiB CHR-ROM bank number := (Output &1) | (~Invert <<1)": CHR A13 is +//! Output bit 0 and CHR A14 is the *inverse* of V, read live rather than +//! latched, so a `$4101` write moves CHR A14 immediately. PRG is a fixed 32 KiB +//! (16 KiB on *Xiao Ma Li*, mirrored). Mirroring is hard-wired. +//! +//! *Xiao Ma Li* replaces the 32 KiB CHR-ROM with an 8 KiB EPROM whose pin 27 is +//! `/PGM` instead of A14, and "setting Invert on this board just disables +//! CHR-ROM". The page does not say what the PPU then reads, so that is not +//! modelled: an 8 KiB CHR image simply wraps every bank onto its one bank. +//! +//! The TXC re-release of *Mahjong Block* (headerless CRC32 `0ACFC3CD`) "is +//! commonly set to mapper 173, but since it expects Output bit 1 to select CHR +//! A14, it actually uses INES Mapper 132". It is a mapper 132 board with a +//! mapper 173 header, and is not emulated as 173 here. + +#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)] + +use crate::cartridge::Mirroring; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name}; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const PRG_BANK_32K: usize = 0x8000; +const CHR_BANK_8K: usize = 0x2000; +const NAMETABLE_SIZE: usize = 0x0400; +const SAVE_STATE_VERSION: u8 = 1; + +/// Reverse bits 0-5 of `v` (bits 6-7 are dropped): the mapper 172 board wires +/// CPU D0-D5 to the JV001's D5-D0. +const fn reverse6(v: u8) -> u8 { + let mut out = 0; + let mut i = 0; + while i < 6 { + if v & (1 << i) != 0 { + out |= 1 << (5 - i); + } + i += 1; + } + out +} + +/// The chip's register window: `$4100-$5FFF` addresses whose `$E100` bits read +/// `$4100`. Both pages decode the read with `$E100` (mapper 172's "Read +/// `$4100-$4103`" under mask `$E103` names all four low addresses, so A0-A1 +/// are don't-care there too). +const fn in_read_window(addr: u16) -> bool { + addr & 0xE100 == 0x4100 +} + +/// Validate the shared ROM geometry: PRG a non-zero multiple of 16 KiB (one or +/// two 16 KiB halves of the fixed 32 KiB window) and non-empty CHR-ROM in +/// 8 KiB banks. +fn check_geometry(id: u16, prg: &[u8], chr: &[u8]) -> Result<(), MapperError> { + if prg.is_empty() || !prg.len().is_multiple_of(0x4000) { + return Err(MapperError::Invalid(format!( + "mapper {id} PRG-ROM size {} is not a non-zero multiple of 16 KiB", + prg.len() + ))); + } + if chr.is_empty() || !chr.len().is_multiple_of(CHR_BANK_8K) { + return Err(MapperError::Invalid(format!( + "mapper {id} CHR-ROM size {} is not a non-zero multiple of 8 KiB", + chr.len() + ))); + } + Ok(()) +} + +/// The fixed 32 KiB PRG window. A larger image keeps its first 32 KiB (neither +/// board has a PRG bank line); a 16 KiB image is mirrored. +fn prg_byte(prg: &[u8], addr: u16) -> u8 { + let off = (usize::from(addr) - 0x8000) % PRG_BANK_32K.min(prg.len()); + prg[off] +} + +fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize { + let table = (((addr - 0x2000) / 0x0400) & 0x03) as u8; + mirroring.physical_bank(table) * NAMETABLE_SIZE + (usize::from(addr) & (NAMETABLE_SIZE - 1)) +} + +// --------------------------------------------------------------------------- +// Mapper 172 +// --------------------------------------------------------------------------- + +/// Mapper 172: the Super Mega P-4070 board (*1991 Du Ma Racing*, the original +/// *Mahjong Block*, *Venice Beach Volley*). See the module docs. +pub struct Txc172 { + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + vram: Box<[u8]>, + /// The six-bit Input latch, chip bit order. + input: u8, + /// The six-bit Register, chip bit order. + register: u8, + /// The six-bit Output latch, chip bit order. + output: u8, + /// Mode: `true` makes a `$4100` write increment instead of load. + increment: bool, + /// Invert. + invert: bool, + /// The mirroring the last `$8000-$FFFF` write chose (the header's until then). + mirroring: Mirroring, +} + +impl Txc172 { + /// Construct the board. + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 16 KiB + /// or CHR-ROM is empty or not a multiple of 8 KiB. + pub fn new( + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + mirroring: Mirroring, + ) -> Result { + check_geometry(172, &prg_rom, &chr_rom)?; + Ok(Self { + prg_rom, + chr_rom, + vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), + input: 0, + register: 0, + output: 0, + increment: false, + invert: false, + mirroring, + }) + } + + /// The value the chip drives for a `$4100` read, in chip bit order: + /// Register with bits 4-5 inverted while Invert is set. + fn chip_read(&self) -> u8 { + self.register ^ if self.invert { 0x30 } else { 0 } + } + + fn chr_bank(&self) -> usize { + usize::from(self.output & 0x03) % (self.chr_rom.len() / CHR_BANK_8K) + } +} + +impl Mapper for Txc172 { + fn caps(&self) -> MapperCaps { + MapperCaps::NONE + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + matches!(addr, 0x4020..=0x7FFF) && !in_read_window(addr) + } + + /// D6-D7 of a `$4100` read are open bus. + fn cpu_read_driven_mask(&self, addr: u16) -> u8 { + if in_read_window(addr) { 0x3F } else { 0xFF } + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + match addr { + 0x8000..=0xFFFF => prg_byte(&self.prg_rom, addr), + _ if in_read_window(addr) => reverse6(self.chip_read()), + _ => 0, + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + // The written value is ignored. + self.output = self.register; + self.mirroring = if self.invert { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + return; + } + match addr & 0xE103 { + 0x4100 => { + self.register = if self.increment { + (self.register & 0x30) | (self.register.wrapping_add(1) & 0x0F) + } else { + self.input ^ if self.invert { 0x0F } else { 0 } + }; + } + // CPU D5 is chip D0 on this board. + 0x4101 => self.invert = value & 0x20 != 0, + 0x4102 => self.input = reverse6(value), + 0x4103 => self.increment = value & 0x20 != 0, + _ => {} + } + } + + fn chr_phys(&self, addr: u16) -> Option { + u32::try_from(self.chr_bank() * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok() + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + if let 0x2000..=0x3EFF = addr { + self.vram[nametable_offset(addr, self.mirroring)] = value; + } + } + + fn current_mirroring(&self) -> Mirroring { + self.mirroring + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = MapperDebugInfo { + mapper_id: 172, + name: "TXC JV001 reversed (172)".into(), + mirroring: mirroring_name(self.mirroring), + ..Default::default() + }; + info.chr_banks + .push(("8K".into(), format!("{:#04x}", self.chr_bank()))); + info.extra.push(( + "register".into(), + format!("{:#04x} (chip order)", self.register), + )); + info.extra + .push(("input".into(), format!("{:#04x}", self.input))); + info.extra + .push(("output".into(), format!("{:#04x}", self.output))); + info.extra.push(( + "mode/invert".into(), + format!("{}/{}", u8::from(self.increment), u8::from(self.invert)), + )); + info + } + + fn save_state(&self) -> Vec { + let mut out = Vec::with_capacity(7 + self.vram.len()); + out.push(SAVE_STATE_VERSION); + out.push(self.input); + out.push(self.register); + out.push(self.output); + out.push(u8::from(self.increment)); + out.push(u8::from(self.invert)); + out.push(u8::from(self.mirroring == Mirroring::Vertical) | 0x80); + out.extend_from_slice(&self.vram); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + let expected = 7 + self.vram.len(); + if data.len() != expected { + return Err(MapperError::WrongLength { + expected, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + self.input = data[1] & 0x3F; + self.register = data[2] & 0x3F; + self.output = data[3] & 0x3F; + self.increment = data[4] != 0; + self.invert = data[5] != 0; + // Bit 7 marks a saved H/V choice; the header's mirroring is the only + // other value the board can hold, and it is H or V on every dump, so + // the bit-0 encoding is complete. + self.mirroring = if data[6] & 0x01 != 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + }; + self.vram.copy_from_slice(&data[7..]); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Mapper 173 +// --------------------------------------------------------------------------- + +/// Mapper 173: Idea-Tek's unmarked ET-xx board (*Puzzle*, *Xiao Ma Li*, +/// *F-15 City War*, *Poker Jingling*, *Zhanguo Sichuan Sheng*). See the module +/// docs. +pub struct Txc173 { + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + vram: Box<[u8]>, + mirroring: Mirroring, + /// "PPP": three bits loaded from `$4102`. + p: u8, + /// "RRR": three bits, loaded or incremented by a `$4100` write. + r: u8, + /// "S": one bit from `$4102` D3, read back immediately (XOR V). + s: bool, + /// Output: the bottom two bits of RRR at the last `$8000-$FFFF` write. + output: u8, + /// "C", increment mode. + increment: bool, + /// "V", invert mode; its inverse is CHR A14. + invert: bool, +} + +impl Txc173 { + /// Construct the board. Mirroring is hard-wired, so the header's stands. + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 16 KiB + /// or CHR-ROM is empty or not a multiple of 8 KiB. + pub fn new( + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + mirroring: Mirroring, + ) -> Result { + check_geometry(173, &prg_rom, &chr_rom)?; + Ok(Self { + prg_rom, + chr_rom, + vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), + mirroring, + p: 0, + r: 0, + s: false, + output: 0, + increment: false, + invert: false, + }) + } + + /// `[xxxx SRRR]`. + fn chip_read(&self) -> u8 { + (self.r & 0x07) | (u8::from(self.s ^ self.invert) << 3) + } + + /// `(Output & 1) | (~Invert << 1)`. + fn chr_bank(&self) -> usize { + let bank = usize::from(self.output & 0x01) | (usize::from(!self.invert) << 1); + bank % (self.chr_rom.len() / CHR_BANK_8K) + } +} + +impl Mapper for Txc173 { + fn caps(&self) -> MapperCaps { + MapperCaps::NONE + } + + fn has_hardwired_mirroring(&self) -> bool { + true + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + matches!(addr, 0x4020..=0x7FFF) && !in_read_window(addr) + } + + /// D4-D7 of a `$4100` read are open bus. + fn cpu_read_driven_mask(&self, addr: u16) -> u8 { + if in_read_window(addr) { 0x0F } else { 0xFF } + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + match addr { + 0x8000..=0xFFFF => prg_byte(&self.prg_rom, addr), + _ if in_read_window(addr) => self.chip_read(), + _ => 0, + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.output = self.r & 0x03; + return; + } + match addr & 0xE103 { + 0x4100 => { + self.r = if self.increment { + self.r.wrapping_add(1) & 0x07 + } else if self.invert { + !self.p & 0x07 + } else { + self.p + }; + } + 0x4101 => self.invert = value & 0x01 != 0, + 0x4102 => { + self.s = value & 0x08 != 0; + self.p = value & 0x07; + } + 0x4103 => self.increment = value & 0x01 != 0, + _ => {} + } + } + + fn chr_phys(&self, addr: u16) -> Option { + u32::try_from(self.chr_bank() * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok() + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + if let 0x2000..=0x3EFF = addr { + self.vram[nametable_offset(addr, self.mirroring)] = value; + } + } + + fn current_mirroring(&self) -> Mirroring { + self.mirroring + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = MapperDebugInfo { + mapper_id: 173, + name: "TXC 05-00002-010 (173)".into(), + mirroring: mirroring_name(self.mirroring), + ..Default::default() + }; + info.chr_banks + .push(("8K".into(), format!("{:#04x}", self.chr_bank()))); + info.extra.push(( + "P/R/S".into(), + format!("{}/{}/{}", self.p, self.r, u8::from(self.s)), + )); + info.extra.push(( + "C/V".into(), + format!("{}/{}", u8::from(self.increment), u8::from(self.invert)), + )); + info + } + + fn save_state(&self) -> Vec { + let mut out = Vec::with_capacity(7 + self.vram.len()); + out.push(SAVE_STATE_VERSION); + out.push(self.p); + out.push(self.r); + out.push(u8::from(self.s)); + out.push(self.output); + out.push(u8::from(self.increment)); + out.push(u8::from(self.invert)); + out.extend_from_slice(&self.vram); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + let expected = 7 + self.vram.len(); + if data.len() != expected { + return Err(MapperError::WrongLength { + expected, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + self.p = data[1] & 0x07; + self.r = data[2] & 0x07; + self.s = data[3] != 0; + self.output = data[4] & 0x03; + self.increment = data[5] != 0; + self.invert = data[6] != 0; + self.vram.copy_from_slice(&data[7..]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + /// An iNES 1.0 image for `mapper`: `prg_16k` x 16 KiB PRG whose every + /// 16 KiB half opens with its index, and `chr_8k` x 8 KiB CHR whose every + /// bank opens with its index. Vertical mirroring in the header. + fn image(mapper: u8, prg_16k: usize, chr_8k: usize) -> Vec { + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = prg_16k as u8; + v[5] = chr_8k as u8; + v[6] = ((mapper & 0x0F) << 4) | 0x01; + v[7] = mapper & 0xF0; + for b in 0..prg_16k { + let mut bank = vec![0u8; 0x4000]; + bank[0] = b as u8; + v.extend_from_slice(&bank); + } + for b in 0..chr_8k { + let mut bank = vec![0u8; CHR_BANK_8K]; + bank[0] = b as u8; + v.extend_from_slice(&bank); + } + v + } + + /// Build through `crate::parse`, so the dispatch arm is under test too. + fn board(mapper: u8, prg_16k: usize, chr_8k: usize) -> Box { + parse(&image(mapper, prg_16k, chr_8k)) + .unwrap_or_else(|e| panic!("mapper {mapper} must parse: {e:?}")) + .1 + } + + #[test] + fn reverse6_reverses_d0_to_d5() { + assert_eq!(reverse6(0b00_0001), 0b10_0000); + assert_eq!(reverse6(0b00_0011), 0b11_0000); + assert_eq!(reverse6(0b10_1100), 0b00_1101); + assert_eq!(reverse6(0xC0), 0, "D6-D7 are not connected"); + } + + // --- 172 --------------------------------------------------------------- + + #[test] + fn m172_parses_with_fixed_32k_prg_and_header_mirroring_until_8000() { + let mut m = board(172, 2, 4); + assert_eq!(m.cpu_read(0x8000), 0); + assert_eq!(m.cpu_read(0xC000), 1, "fixed 32 KiB, no PRG banking"); + assert_eq!(m.current_mirroring(), Mirroring::Vertical, "header"); + assert!(!m.has_hardwired_mirroring(), "software-controlled"); + } + + #[test] + fn m172_load_and_read_back_are_bit_reversed() { + let mut m = board(172, 2, 4); + // CPU $01 is chip bit 5; CPU $08 is chip bit 2. + m.cpu_write(0x4102, 0x09); + m.cpu_write(0x4100, 0); // Mode 0, Invert 0: Register := Input. + assert_eq!(m.cpu_read(0x4100), 0x09, "reversed in, reversed out"); + assert_eq!(m.cpu_read(0x4103), 0x09, "all of $4100-$4103 read"); + assert_eq!(m.cpu_read(0x5F01), 0x09, "mask $E100"); + assert_eq!(m.cpu_read_driven_mask(0x4100), 0x3F, "D6-D7 open bus"); + assert!(!m.cpu_read_unmapped(0x4100)); + assert!(m.cpu_read_unmapped(0x4000 + 0x20), "outside the mask"); + assert!(m.cpu_read_unmapped(0x4200)); + assert!(m.cpu_read_unmapped(0x6100), "A13 set is not the chip"); + } + + #[test] + fn m172_invert_flips_loaded_bits_0_3_and_read_bits_4_5() { + let mut m = board(172, 2, 4); + m.cpu_write(0x4101, 0x20); // CPU D5 = chip D0: Invert := 1. + m.cpu_write(0x4102, 0x00); // Input := 0. + m.cpu_write(0x4100, 0); // Register := Input ^ 0x0F (chip order). + // Chip register 0x0F; read inverts chip bits 4-5 -> 0x3F; reversed + // onto the bus -> 0x3F. + assert_eq!(m.cpu_read(0x4100), 0x3F); + m.cpu_write(0x4101, 0x00); // Invert := 0. + // Chip 0x0F reversed: chip bits 0-3 are CPU bits 5-2 -> 0x3C. + assert_eq!(m.cpu_read(0x4100), 0x3C); + m.cpu_write(0x4101, 0x01); // CPU D0 is chip D5: not Invert. + assert_eq!(m.cpu_read(0x4100), 0x3C, "Invert is CPU D5 only"); + } + + #[test] + fn m172_increment_wraps_bits_0_3_and_keeps_4_5() { + let mut m = board(172, 2, 4); + // Chip register 0x3F: load chip Input 0x3F (CPU 0x3F reversed). + m.cpu_write(0x4102, 0x3F); + m.cpu_write(0x4100, 0); + m.cpu_write(0x4103, 0x20); // Mode := 1 (CPU D5). + m.cpu_write(0x4100, 0); // Bits 0-3: 0xF + 1 -> 0x0; bits 4-5 kept. + // Chip 0x30 reversed -> CPU 0x03. + assert_eq!(m.cpu_read(0x4100), 0x03); + m.cpu_write(0x4100, 0); + // Chip 0x31 reversed -> CPU 0x23. + assert_eq!(m.cpu_read(0x4100), 0x23); + m.cpu_write(0x4103, 0x01); // CPU D0 is not Mode: back to load. + m.cpu_write(0x4100, 0); + assert_eq!(m.cpu_read(0x4100), 0x3F, "Mode 0 reloads Input"); + } + + #[test] + fn m172_8000_latches_output_into_chr_and_sets_mirroring_from_invert() { + let mut m = board(172, 2, 4); + // Chip register 0x02 (CHR bank 2): CPU 0x10 reversed is chip 0x02. + m.cpu_write(0x4102, 0x10); + m.cpu_write(0x4100, 0); + assert_eq!(m.ppu_read(0x0000), 0, "Output not yet latched"); + m.cpu_write(0x8000, 0xFF); // The written value is ignored. + assert_eq!(m.ppu_read(0x0000), 2); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal, "Invert 0"); + // Chip 0x01 = CPU 0x20: CHR bank 1. Invert set before the latch. + m.cpu_write(0x4102, 0x20); + m.cpu_write(0x4100, 0); + m.cpu_write(0x4101, 0x20); + m.cpu_write(0xFFFF, 0x00); + assert_eq!(m.ppu_read(0x1FFF - 0x1FFF), 1); + assert_eq!(m.current_mirroring(), Mirroring::Vertical, "Invert 1"); + // Chip bits 2-5 of Output do not reach CHR (32 KiB, A13-A14 only). + m.cpu_write(0x4101, 0); + m.cpu_write(0x4102, 0x01); // chip 0x20 + m.cpu_write(0x4100, 0); + m.cpu_write(0x8000, 0); + assert_eq!(m.ppu_read(0x0000), 0); + } + + #[test] + fn m172_state_round_trips() { + let mut a = board(172, 2, 4); + a.cpu_write(0x4102, 0x2A); + a.cpu_write(0x4101, 0x20); + a.cpu_write(0x4100, 0); + a.cpu_write(0x8000, 0); + a.cpu_write(0x4103, 0x20); + a.ppu_write(0x2005, 0x77); + let blob = a.save_state(); + let mut b = board(172, 2, 4); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.cpu_read(0x4100), a.cpu_read(0x4100)); + assert_eq!(b.ppu_read(0x0000), a.ppu_read(0x0000)); + assert_eq!(b.current_mirroring(), a.current_mirroring()); + assert!(b.load_state(&blob[1..]).is_err()); + } + + // --- 173 --------------------------------------------------------------- + + #[test] + fn m173_parses_with_hardwired_mirroring_and_mirrors_16k_prg() { + let mut m = board(173, 1, 1); + assert_eq!(m.cpu_read(0x8000), 0); + assert_eq!(m.cpu_read(0xC000), 0, "16 KiB mirrored"); + assert!(m.has_hardwired_mirroring()); + assert_eq!(m.current_mirroring(), Mirroring::Vertical); + let mut m = board(173, 2, 4); + assert_eq!(m.cpu_read(0xC000), 1, "32 KiB fixed"); + } + + #[test] + fn m173_read_is_s_xor_v_over_rrr_with_d4_d7_open() { + let mut m = board(173, 2, 4); + m.cpu_write(0x4102, 0x0D); // S = 1, PPP = 5. + assert_eq!( + m.cpu_read(0x4100), + 0x08, + "S reads back at once, RRR not yet" + ); + m.cpu_write(0x4100, 0); // RRR := PPP. + assert_eq!(m.cpu_read(0x4100), 0x0D); + assert_eq!(m.cpu_read(0x41FF), 0x0D, "mask $E100"); + assert_eq!(m.cpu_read_driven_mask(0x4100), 0x0F); + m.cpu_write(0x4101, 0x01); // V = 1: S XOR V = 0. + assert_eq!(m.cpu_read(0x4100), 0x05, "RRR itself is not inverted"); + assert!(m.cpu_read_unmapped(0x4000 + 0x80)); + assert!(m.cpu_read_unmapped(0x7100)); + } + + #[test] + fn m173_load_inverts_ppp_under_v_and_increment_wraps_three_bits() { + let mut m = board(173, 2, 4); + m.cpu_write(0x4102, 0x02); + m.cpu_write(0x4101, 0x01); + m.cpu_write(0x4100, 0); + assert_eq!(m.cpu_read(0x4100) & 0x07, 0x05, "~PPP"); + m.cpu_write(0x4103, 0x01); // C = 1. + m.cpu_write(0x4100, 0); + m.cpu_write(0x4100, 0); + m.cpu_write(0x4100, 0); + assert_eq!(m.cpu_read(0x4100) & 0x07, 0x00, "5 + 3 wraps in 3 bits"); + // Mask $E103: A2-A7 are not decoded, so $4104 is $4100; an address + // with A8 clear ($4020) is not the chip. + m.cpu_write(0x4104, 0); + assert_eq!(m.cpu_read(0x4100) & 0x07, 0x01, "$4104 mirrors $4100"); + m.cpu_write(0x4020, 0); + assert_eq!(m.cpu_read(0x4100) & 0x07, 0x01, "$4020 is not decoded"); + } + + #[test] + fn m173_chr_is_output_bit_0_and_inverse_v() { + let mut m = board(173, 2, 4); + // V = 0: CHR A14 = 1 -> banks 2/3. + m.cpu_write(0x4102, 0x01); + m.cpu_write(0x4100, 0); + assert_eq!(m.ppu_read(0x0000), 2, "Output 0, ~V = 1"); + m.cpu_write(0x8000, 0); + assert_eq!(m.ppu_read(0x0000), 3, "Output bit 0 = 1"); + // V moves CHR A14 at once, without a $8000 latch. + m.cpu_write(0x4101, 0x01); + assert_eq!(m.ppu_read(0x0000), 1, "~V = 0"); + // Output bit 1 does not reach CHR on this board. + m.cpu_write(0x4101, 0x00); + m.cpu_write(0x4102, 0x02); + m.cpu_write(0x4100, 0); + m.cpu_write(0x8000, 0); + assert_eq!(m.ppu_read(0x0000), 2, "RRR = 2: Output bit 0 clear"); + } + + #[test] + fn m173_state_round_trips() { + let mut a = board(173, 2, 4); + a.cpu_write(0x4102, 0x0B); + a.cpu_write(0x4100, 0); + a.cpu_write(0x8000, 0); + a.cpu_write(0x4103, 0x01); + a.cpu_write(0x4101, 0x01); + a.ppu_write(0x2400, 0x12); + let blob = a.save_state(); + let mut b = board(173, 2, 4); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.cpu_read(0x4100), a.cpu_read(0x4100)); + assert_eq!(b.ppu_read(0x0000), a.ppu_read(0x0000)); + assert_eq!(b.ppu_read(0x2400), 0x12); + } + + #[test] + fn rejects_bad_geometry() { + assert!(Txc172::new(vec![0; 0x4000].into(), Box::default(), Mirroring::Vertical).is_err()); + assert!( + Txc173::new( + vec![0; 0x1000].into(), + vec![0; 0x2000].into(), + Mirroring::Vertical + ) + .is_err() + ); + } +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index e46b5354..39737a65 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -132,7 +132,12 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // GTROM (111), promoted from BestEffort once the board matched its // page (register window, bonus RAM, self-flashing) and a CC0 fixture // pinned it. None has a redistributable commercial ROM. - 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => { + // + // v3.2.0 joins this arm: families written from a NESdev page with + // exact register masks, each also booted on a local dump recorded with + // its title, header-excluded hash, frames and a committed screenshot + // (D23, `docs/compatibility.md`). + 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 | 172 | 173 => { Some(MapperTier::Curated) } @@ -242,7 +247,7 @@ mod tests { 178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". - 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, + 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 83b1bb3f..f8bbb5dd 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1418,3 +1418,26 @@ fn extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu() { "extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu", ); } + +// ============================================================ +// v3.2.0 — boards written from their NESdev pages, each verified on a local +// dump (D23 records in docs/compatibility.md). +// ============================================================ + +#[test] +fn extended_m172_1991_du_ma_racing() { + check( + "mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].zip", + DEFAULT_IDLE, + "extended_m172_1991_du_ma_racing", + ); +} + +#[test] +fn extended_m173_xiao_ma_li() { + check( + "mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].zip", + DEFAULT_IDLE, + "extended_m173_xiao_ma_li", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_172_TXC_JV001_1991_Du_Ma_Racing_Ch_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_172_TXC_JV001_1991_Du_Ma_Racing_Ch_zip.snap new file mode 100644 index 00000000..4ab16c82 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_172_TXC_JV001_1991_Du_Ma_Racing_Ch_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].zip +rom_sha256=3927e51e75aa1ed430f9ce378ea25de9cb181dc2f238962195c84d239ba0ec3d +frames=1141 +fb_bytes=245760 +cycles=33949773 +audio_samples=836505 +audio_fnv1a64=e44d64d8882f378b +checkpoint f900 fb_fnv1a64=fe411a4789ec8222 +checkpoint f1100 fb_fnv1a64=1adbeb95005a5f5d diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_173_TXC_05_00002_010_Xiao_Ma_Li_Ch_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_173_TXC_05_00002_010_Xiao_Ma_Li_Ch_zip.snap new file mode 100644 index 00000000..59712973 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_173_TXC_05_00002_010_Xiao_Ma_Li_Ch_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].zip +rom_sha256=2844a2b8807507947b08d5fb3de79c9f41f1e9df741c6c57bf64bf94ecb3bd48 +frames=1141 +fb_bytes=245760 +cycles=33949774 +audio_samples=836505 +audio_fnv1a64=61aadefced601391 +checkpoint f900 fb_fnv1a64=c2ba11f55a32fa2f +checkpoint f1100 fb_fnv1a64=c2ba11f55a32fa2f diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m172_1991_du_ma_racing.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m172_1991_du_ma_racing.snap new file mode 100644 index 00000000..5e360f22 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m172_1991_du_ma_racing.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].zip +rom_sha256=3927e51e75aa1ed430f9ce378ea25de9cb181dc2f238962195c84d239ba0ec3d +frames=600 +fb_bytes=245760 +cycles=17838521 +audio_samples=439524 +audio_fnv1a64=f3e8f84b44ea5cee +checkpoint f600 fb_fnv1a64=03c22bdd970c7e34 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m173_xiao_ma_li.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m173_xiao_ma_li.snap new file mode 100644 index 00000000..a6dc2214 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m173_xiao_ma_li.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].zip +rom_sha256=2844a2b8807507947b08d5fb3de79c9f41f1e9df741c6c57bf64bf94ecb3bd48 +frames=600 +fb_bytes=245760 +cycles=17838521 +audio_samples=439524 +audio_fnv1a64=dda9e74ae2696052 +checkpoint f600 fb_fnv1a64=636e3ca32ed79786 diff --git a/docs/compatibility.md b/docs/compatibility.md index 73137556..3a193773 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -362,3 +362,16 @@ FE-11, which the v3.1 → v4.0 line plan does not schedule: - **CRC32 vs. SHA-256 for ROM identification.** We use SHA-256 for save state directory naming (lower collision risk). For ROM compatibility databases, CRC32 is the community standard; we may add it as a secondary key. - **Region override.** Some users want to play PAL versions of NTSC games at NTSC speed. Plan: expose a region override in the settings UI; warn that this may break timing-sensitive ROMs. + +## Curated evidence from local dumps (D23) + +A local dump counts as Curated evidence when the record names the title, the +SHA-256 of the bytes after the 16-byte header (`Nes::rom_sha256`), the frames +run, and a committed screenshot of the frame the boot snapshot pins. The dumps +are gitignored (`tests/roms/external/`); the byte-identity boot snapshots are +in `crates/rustynes-test-harness/tests/external_extended.rs`. + +| mapper | title | SHA-256 of the bytes after the header (`Nes::rom_sha256`) | frames | screenshot | +|--------|-------|-----------------------------------------------------------|--------|------------| +| 172 | 1991 Du Ma Racing (Ch) [!] | `4a2ec0f51526ad9659b0ede758216ea8f7acae7d49faaed5db46b6acc7be6915` | 600 | `screenshots/external/mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].png` | +| 173 | Xiao Ma Li (Ch) [!] | `103e27a9b2e2c029fee9a221e2ffe35b5b0b4a3e529a4df88fd6d71f21cf56a4` | 600 | `screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index a93c5a69..e463a121 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -838,6 +838,20 @@ path), were all CAUGHT by the unit tests or `roster_boards.rs`. Two needed a new test first: the bus's floating-read call, which no mapper-level test can reach, and the PPU's unfolded `$3xxx` path. The record is in the v2.9.6 plan. +### v3.2.0 batch: TXC, Tengen, Taito, Waixing, reset multicarts, FFE + +Written from the vendored NESdev page named in each row; no reference-emulator +source was read. A family is **Curated** when its page gives exact register +masks AND a local dump boots to a correct title or gameplay screen, recorded +under D23 in `docs/compatibility.md` (title, header-excluded SHA-256, frames, +committed screenshot) and pinned by a byte-identity boot snapshot in +`external_extended.rs`. Otherwise it is **BestEffort**, with the reason here. + +| iNES | Sub | Board (page) | Tier | IRQ | Notes | +|------|-----|--------------|------|-----|-------| +| 172 | — | Super Mega P-4070, JV001 (`INES_Mapper_172`, `INES_Mapper_136`) | Curated | — | `m172_m173_txc.rs`. The JV001's Input / Register / Output with CPU D0-D5 wired in reverse; Mode and Invert from CPU D5. `$8000-$FFFF` latches Output (CHR bank = Output & 3 in chip order) and sets mirroring from Invert (H when clear, V when set); the header's mirroring holds until then. PRG fixed 32 KiB. `$4100` reads drive D0-D5 only. *1991 Du Ma Racing* passes its protection check and plays. | +| 173 | — | Idea-Tek ET-xx, 05-00002-010 (`INES_Mapper_173`) | Curated | — | Same module. P/R/Output 3 bits, S/C/V 1 bit; `$4100` reads `[xxxx SRRR]` with S XOR V, D4-D7 open bus. CHR bank = (Output & 1) \| (~V << 1), V read live. Hard-wired mirroring. *Xiao Ma Li*'s 8 KiB EPROM with `/PGM` on pin 27 is not modelled beyond wrapping (the page does not say what a disabled CHR read returns). The TXC *Mahjong Block* (CRC32 `0ACFC3CD`), commonly headered 173, is a mapper 132 board per both pages: as 173 it draws garbage, as the page predicts. | + ### Mapper accuracy tiering (v1.2.0) Every supported family is classified `Core` / `Curated` / `BestEffort` by diff --git a/screenshots/external/mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].png b/screenshots/external/mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..b80d6a0fb53cab556dc058c4211e80ab1270daf6 GIT binary patch literal 5455 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png b/screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..0f17d31705332ff189de16c6f68b2a12f34ca441 GIT binary patch literal 4699 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 Qn?}IqjDuCVh4+L11I}=n4*&oF literal 0 HcmV?d00001 From 8b4b407df2ee77c77dd117ea24730c2ffe8068af Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 19:29:55 -0400 Subject: [PATCH 004/104] feat(mappers): add mapper 114/182 (SuperGame / Hosenkan MMC3 scramble) Mapper 114 is an MMC3 clone whose register addresses and bank-select indices are permuted, with two board registers beside it. It is written from the vendored INES_Mapper_114.md and MMC3.md; no reference-emulator source was read. The board (new module mmc3_boards_ext.rs) - CPU writes to $8000-$FFFF are unscrambled before the MMC3 sees them: the $E001-masked address goes through the page's address table, and when the real target is $8000 the low three bits of the value go through the index table. Bits 3-7 pass through (the page: "bits 6 and 7 ... are unchanged"), so the PRG and CHR mode bits are not touched. Both tables are transcribed literally: submapper 0 (Aladdin, The Lion King, Hosenkan's games) and submapper 1 (Boogerman). - $6000 (mask "probably $E001"): M.S. BBBb. With M set, the 16 KiB bank BBBb replaces both MMC3 PRG halves; with S set CPU A14 replaces b (NROM-256). $6001 bit 0 is CHR A18. Neither is on the MMC3's WRAM interface, so both are taken whatever $A001 says. - IRQ: the page says it resembles the MMC3A (a latch of 0 disables it, which Aladdin depends on), which the project's Mmc3Revision::Nec models. - The MMC3 is the project's own Mmc3 used as a register file and IRQ counter (Mmc3::register_core), as mmc3_boards.rs does, so the A12 filter and the one-cycle IRQ assertion delay are shared. A separate module because the board logic sits in FRONT of the MMC3 (a scramble) rather than behind its bank outputs, and to keep it apart from mmc3_clones.rs and its Mesen2 derivation record. mmc3_boards.rs's mirror_bank (the NESdev non-power-of-two doubling) is made pub(crate) and reused; no behaviour of that file changes. 182: the 182 page is a one-line redirect to 114, and the 114 page calls 182 "a duplicate of Mapper 114" whose Hosenkan games only ever write zero to $6000/$6001. Decision: 182 dispatches to 114 submapper 0 with the full model. The zero writes leave the board in the MMC3's own banking, so there is nothing to gain from the "no $600x registers" shortcut the page says some implementations take. Verification - Red first: a parse-level test of 114.0, 114.1 and 182 failed with the ids unsupported, then passed once dispatched. - 9 register-decode unit tests (mmc3_boards_ext_tests.rs): every row of both address and index tables, written as literal page rows rather than lookups in the code's tables; the mode bits passing through; the IRQ registers at their scrambled addresses and the MMC3A zero latch; the NROM-128 and NROM-256 override; CHR A18 and both $600x registers working with WRAM disabled; 182 -> 114.0; state round trip, variant refusal and an overflowing embedded length. - Boot snapshots (external_extended.rs, 600 frames idle): Aladdin (SuperGame) (Mapper 114) [!] boots to its title, the logo over the palace with the raster split drawn cleanly; Super Donkey Kong (Unl) [!] (mapper 182) boots to its Donkey Kong title. Both screenshots committed; D23 records (Nes::rom_sha256, frames, path) added under a new "Curated evidence from local dumps (D23)" section of docs/compatibility.md. - Tier: Curated for both (114 and 182), on the D23 records. - Mutation: submapper 0's index table with entries 4 and 5 swapped (written 4 -> R7, 5 -> R6). Caught by two unit tests ("written index 4 must reach R6", left 0 right 18) and by both boot snapshots (f600 framebuffer hash changed on both). Restored from a copy. Not verified - Submapper 1 has no dump that carries a submapper-1 header: GoodNES labels Boogerman as mapper 215, the assignment the 215 page says was later withdrawn. Its table is pinned by unit tests only. - The $6000/$6001 mask is "probably" on the page. - The external_coverage harness will discover the newly staged dumps; its baselines are not blessed here. - mapper_tier_honesty currently fails in this worktree on mapper 157 dumps another agent staged in the shared corpus, not on these ids. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 34 ++ crates/rustynes-mappers/src/mmc3_boards.rs | 2 +- .../rustynes-mappers/src/mmc3_boards_ext.rs | 448 ++++++++++++++++++ .../src/mmc3_boards_ext_tests.rs | 287 +++++++++++ crates/rustynes-mappers/src/tier.rs | 11 +- .../tests/external_extended.rs | 24 + ...nded__extended_m114_aladdin_supergame.snap | 12 + ...nded__extended_m182_super_donkey_kong.snap | 12 + docs/compatibility.md | 13 + docs/mappers.md | 17 + .../Aladdin (SuperGame) (Mapper 114) [!].png | Bin 0 -> 8277 bytes .../Super Donkey Kong (Unl) [!].png | Bin 0 -> 4351 bytes 12 files changed, 858 insertions(+), 2 deletions(-) create mode 100644 crates/rustynes-mappers/src/mmc3_boards_ext.rs create mode 100644 crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_aladdin_supergame.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m182_super_donkey_kong.snap create mode 100644 screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png create mode 100644 screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index eefad19d..f78d97c7 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -136,6 +136,7 @@ mod m268_bmc_coolboy; mod m513_sachen_9602; mod mapper; mod mmc3_boards; +mod mmc3_boards_ext; mod mmc3_clones; mod multicart_discrete; mod nsf; @@ -249,6 +250,7 @@ pub use mapper::{ mirroring_name, }; pub use mmc3_boards::{Board as Mmc3BoardKind, Mmc3Board}; +pub use mmc3_boards_ext::{ExtBoard as Mmc3ExtBoardKind, Mmc3BoardExt}; pub use mmc3_clones::{ Mmc3CloneMapper, new_m44, new_m49, new_m52, new_m115, new_m134, new_m189, new_m205, new_m238, new_m245, new_m348, new_m366, @@ -301,6 +303,26 @@ fn mmc3_board( )) } +/// Build one of the v3.2.0 MMC3 boards (`mmc3_boards_ext.rs`). Work RAM is +/// taken only from a NES 2.0 header, for the reason given on [`mmc3_board`]: +/// iNES 1.0 reports a nominal 8 KiB for every image. +fn mmc3_ext( + kind: Mmc3ExtBoardKind, + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + h: &Header, +) -> Result, RomError> { + let wram = if h.is_nes2 { + h.prg_ram_window() as usize + } else { + 0 + }; + Ok(Box::new( + Mmc3BoardExt::new(kind, prg_rom, chr_rom, h.mirroring, wram) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + )) +} + /// Parse an iNES 1.0 / NES 2.0 ROM file. /// /// Validates the magic, detects the format, applies the appropriate sizing @@ -1377,6 +1399,18 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { 192 => mmc3_board(Mmc3BoardKind::M192, prg_rom, chr_rom, &h)?, 194 => mmc3_board(Mmc3BoardKind::M194, prg_rom, chr_rom, &h)?, 195 => mmc3_board(Mmc3BoardKind::M195, prg_rom, chr_rom, &h)?, + // v3.2.0: the SuperGame / Hosenkan scramblers (`mmc3_boards_ext.rs`). + // 182 is a duplicate of 114 (`INES_Mapper_114.md`, "Similar + // Mappers"; the 182 page is a redirect), so it takes submapper 0. + 114 => mmc3_ext( + Mmc3ExtBoardKind::M114 { + sub1: h.submapper == 1, + }, + prg_rom, + chr_rom, + &h, + )?, + 182 => mmc3_ext(Mmc3ExtBoardKind::M114 { sub1: false }, prg_rom, chr_rom, &h)?, 44 => Box::new( new_m44(prg_rom, chr_rom, h.mirroring) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, diff --git a/crates/rustynes-mappers/src/mmc3_boards.rs b/crates/rustynes-mappers/src/mmc3_boards.rs index 9d383004..c6e4f192 100644 --- a/crates/rustynes-mappers/src/mmc3_boards.rs +++ b/crates/rustynes-mappers/src/mmc3_boards.rs @@ -219,7 +219,7 @@ fn t9552_scramble( /// power-of-two `count` it is exactly `bank % count`. No allocation, a few /// iterations at most (one per set bit of `count`): this runs on every PRG and /// CHR-ROM access. -fn mirror_bank(bank: usize, count: usize) -> usize { +pub(crate) fn mirror_bank(bank: usize, count: usize) -> usize { if count == 0 { return 0; } diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext.rs b/crates/rustynes-mappers/src/mmc3_boards_ext.rs new file mode 100644 index 00000000..30f7ee4d --- /dev/null +++ b/crates/rustynes-mappers/src/mmc3_boards_ext.rs @@ -0,0 +1,448 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! MMC3-clone boards added in v3.2.0, written from their NESdev pages: +//! mappers 114 and 182 (the SuperGame / Hosenkan register scramblers). +//! +//! These are, like the boards in `mmc3_boards.rs`, a stock MMC3 with a +//! board-specific layer in front of it or behind it. The difference that +//! earns them a module of their own is *where* the layer sits: these boards +//! rewrite the CPU's register writes before the MMC3 sees them (an address +//! and index scramble), or rewire the PPU address lines the MMC3 sees, rather +//! than only post-processing the MMC3's bank outputs. The MMC3 itself is still +//! the project's own [`Mmc3`] used as a register file and IRQ counter +//! ([`Mmc3::register_core`]), so the A12 filter and the Sharp / NEC IRQ +//! revisions of `m004_mmc3.rs` are shared, and the mapper 4 path is untouched. +//! +//! **Provenance.** Each board is implemented from the vendored NESdev page +//! named on its [`ExtBoard`] variant (`nesdev_wiki/output/INES_Mapper_NNN.md`) +//! and from `MMC3.md`. No reference-emulator source was read. This file is +//! kept apart from `mmc3_clones.rs` for the same reason `mmc3_boards.rs` is: +//! that file carries a Mesen2 derivation record, and independently written +//! boards must not blur which regions it covers. +//! +//! **Bank arithmetic.** Every bank is reduced onto the image by +//! [`mirror_bank`] (the NESdev doubling rule for non-power-of-two sizes), so no +//! register value can index out of bounds. The chip stack is `#![no_std]` and +//! cannot afford a panic on a register write. +//! +//! ## Mapper 114 / 182 (`INES_Mapper_114.md`) +//! +//! An MMC3 clone whose register *addresses* and `$8000` *indices* are +//! permuted, plus two board registers that are not on the MMC3's WRAM +//! interface (they answer whether or not WRAM is enabled): +//! +//! - `$6000` (mask `$E001`): `M.S. BBBb`. With `M` set, a 16 KiB NROM-style +//! bank `BBBb` replaces the MMC3's PRG banks at both halves; `S` set makes it +//! NROM-256 by substituting CPU A14 for `b`. +//! - `$6001` (mask `$E001`): bit 0 is CHR A18, a 256 KiB outer CHR bank. +//! +//! Submapper 0 (Aladdin, The Lion King, Hosenkan's titles) and submapper 1 +//! (Boogerman) use different permutations; both tables are on the page and are +//! transcribed into [`M114_SUB0`] / [`M114_SUB1`]. The page states the IRQ +//! behaves like the MMC3A's (a latch of 0 disables it, which Aladdin needs), +//! so the core runs [`Mmc3Revision::Nec`], the project's name for that +//! behaviour. +//! +//! **Mapper 182 is mapper 114.** `INES_Mapper_182.md` is a one-line redirect +//! to the 114 page, which says 182 "is a duplicate of Mapper 114" and that +//! Hosenkan's 182 games write zero to `$6000` / `$6001` once and never again. +//! So 182 is dispatched to mapper 114 submapper 0 with no separate model. The +//! zero writes are harmless under the full 114 model (NROM override off, CHR +//! A18 = 0), so there is no reason to model the "no `$600x` registers" +//! simplification some implementations use. + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_lossless, + clippy::missing_const_for_fn, + clippy::doc_markdown +)] + +use crate::cartridge::Mirroring; +use crate::m004_mmc3::{Mmc3, Mmc3Revision}; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError}; +use crate::mmc3_boards::mirror_bank; +use alloc::string::ToString; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const PRG_BANK_8K: usize = 0x2000; +const CHR_BANK_1K: usize = 0x0400; + +/// Save-state layout version for [`Mmc3BoardExt`]. +const SAVE_STATE_VERSION: u8 = 1; + +/// Fixed head of the save state: version, id, variant tag, four registers, +/// one latch byte, and the embedded MMC3 state's length. +const HEAD: usize = 3 + 4 + 1 + 4; + +/// Which board an [`Mmc3BoardExt`] models. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExtBoard { + /// Mapper 114 (and 182), `INES_Mapper_114.md`. `sub1` selects the + /// submapper 1 (Boogerman) scrambling pattern. + M114 { + /// NES 2.0 submapper 1. + sub1: bool, + }, +} + +impl ExtBoard { + const fn id(self) -> u16 { + match self { + Self::M114 { .. } => 114, + } + } + + const fn name(self) -> &'static str { + match self { + Self::M114 { sub1: false } => "SuperGame MMC3 scramble (114.0)", + Self::M114 { sub1: true } => "SuperGame MMC3 scramble (114.1)", + } + } + + /// A one-byte tag for the save state, distinct per variant so a state + /// cannot be restored onto a different wiring of the same mapper. + const fn state_tag(self) -> u8 { + match self { + Self::M114 { sub1: false } => 0, + Self::M114 { sub1: true } => 1, + } + } +} + +/// A register scramble: `addr[i]` is the MMC3 register (an `$E001`-masked +/// address) that a write to the `i`-th of `$8000, $8001, $A000, $A001, $C000, +/// $C001, $E000, $E001` reaches, and `index[v]` is the MMC3 bank-select index +/// that a written index `v` (the low three bits of a `$8000` value) means. +struct Scramble { + addr: [u16; 8], + index: [u8; 8], +} + +/// Mapper 114 submapper 0, transcribed from the page's two tables. +const M114_SUB0: Scramble = Scramble { + addr: [ + 0xA001, 0xA000, 0x8000, 0xC000, 0x8001, 0xC001, 0xE000, 0xE001, + ], + index: [0, 3, 1, 5, 6, 7, 2, 4], +}; + +/// Mapper 114 submapper 1 ("different scrambling pattern"). +const M114_SUB1: Scramble = Scramble { + addr: [ + 0xA001, 0x8001, 0x8000, 0xC001, 0xA000, 0xC000, 0xE000, 0xE001, + ], + index: [0, 2, 5, 3, 6, 1, 7, 4], +}; + +/// The position of an `$E001`-masked MMC3 address in a [`Scramble`] row. +const fn scramble_slot(addr: u16) -> usize { + ((((addr >> 13) & 3) << 1) | (addr & 1)) as usize +} + +/// Unscramble one CPU write: the real MMC3 register address, and the value +/// with its index bits rewritten when that register is `$8000` (bank select). +/// Bits 3-7 of a `$8000` value pass through ("bits 6 and 7 ... are unchanged"; +/// bits 3-5 are unused by the MMC3). +fn unscramble(table: &Scramble, addr: u16, value: u8) -> (u16, u8) { + let real = table.addr[scramble_slot(addr)]; + let value = if real == 0x8000 { + (value & 0xF8) | table.index[usize::from(value & 7)] + } else { + value + }; + (real, value) +} + +/// The NROM override shared by mappers 114 and 215: `M.S. BBBb`. With `M` +/// (bit 7) set, the 16 KiB bank `BBBb` replaces both MMC3 halves; with `S` +/// (bit 5) set, CPU A14 replaces `b` (NROM-256). Returns the 8 KiB bank, or +/// `None` while the MMC3's own banks apply. +fn nrom_override(reg: u8, addr: u16) -> Option { + if reg & 0x80 == 0 { + return None; + } + let mut bank16 = usize::from(reg & 0x0F); + if reg & 0x20 != 0 { + bank16 = (bank16 & !1) | usize::from((addr >> 14) & 1); + } + Some((bank16 << 1) | usize::from((addr >> 13) & 1)) +} + +/// An MMC3 board with a scramble or rewiring layer. See the module docs. +pub struct Mmc3BoardExt { + board: ExtBoard, + core: Mmc3, + prg_rom: Box<[u8]>, + /// CHR-ROM, or 8 KiB of CHR-RAM when the image has none. + chr: Box<[u8]>, + chr_is_ram: bool, + /// Work RAM at `$6000-$7FFF`, only when a NES 2.0 header declares it. + wram: Box<[u8]>, + /// Board registers. 114: `regs[0]` = `$6000`, `regs[1]` = `$6001`. + regs: [u8; 4], +} + +impl Mmc3BoardExt { + /// Construct a board. `wram_bytes` is the NES 2.0 PRG-RAM size, or 0. + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 8 KiB + /// or CHR is not a multiple of 1 KiB. + pub fn new( + board: ExtBoard, + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + mirroring: Mirroring, + wram_bytes: usize, + ) -> Result { + let id = board.id(); + if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) { + return Err(MapperError::Invalid(format!( + "mapper {id} PRG-ROM size {} is not a non-zero multiple of 8 KiB", + prg_rom.len() + ))); + } + if !chr_rom.len().is_multiple_of(CHR_BANK_1K) { + return Err(MapperError::Invalid(format!( + "mapper {id} CHR-ROM size {} is not a multiple of 1 KiB", + chr_rom.len() + ))); + } + let chr_is_ram = chr_rom.is_empty(); + let chr = if chr_is_ram { + vec![0u8; 8 * CHR_BANK_1K].into_boxed_slice() + } else { + chr_rom + }; + // 114: "IRQ behavior resembles that of the MMC3A, i.e. a latch value + // of zero disables the IRQ, on which Aladdin depends." + let revision = match board { + ExtBoard::M114 { .. } => Mmc3Revision::Nec, + }; + Ok(Self { + board, + core: Mmc3::register_core(mirroring, revision), + prg_rom, + chr, + chr_is_ram, + wram: vec![0u8; wram_bytes].into_boxed_slice(), + regs: [0; 4], + }) + } + + /// The 8 KiB PRG bank for `addr` (`$8000-$FFFF`), before the mirror. + fn prg_bank(&self, addr: u16) -> usize { + let raw = self.core.prg_bank_raw(addr) as usize; + match self.board { + ExtBoard::M114 { .. } => nrom_override(self.regs[0], addr).unwrap_or(raw), + } + } + + /// The 1 KiB CHR-ROM bank for a pattern-table address, before the mirror. + fn chr_bank(&self, addr: u16) -> usize { + let raw = self.core.chr_bank_1k(addr); + match self.board { + // `$6001` bit 0 is CHR A18 over the MMC3's A10-A17. + ExtBoard::M114 { .. } => (raw & 0xFF) | (usize::from(self.regs[1] & 1) << 8), + } + } + + fn chr_offset(&self, addr: u16) -> usize { + let addr = addr & 0x1FFF; + let bank = mirror_bank(self.chr_bank(addr), self.chr.len() / CHR_BANK_1K); + bank * CHR_BANK_1K + (usize::from(addr) & (CHR_BANK_1K - 1)) + } + + fn write_high(&mut self, addr: u16, value: u8) { + let (addr, value) = match self.board { + ExtBoard::M114 { sub1: false } => unscramble(&M114_SUB0, addr, value), + ExtBoard::M114 { sub1: true } => unscramble(&M114_SUB1, addr, value), + }; + self.core.cpu_write(addr, value); + } +} + +impl Mapper for Mmc3BoardExt { + fn sram(&self) -> &[u8] { + &self.wram + } + + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.wram + } + + fn caps(&self) -> MapperCaps { + MapperCaps::CYCLE_IRQ + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + match addr { + 0x4020..=0x5FFF => true, + 0x6000..=0x7FFF => self.wram.is_empty() || !self.core.prg_ram_enabled(), + _ => false, + } + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + match addr { + 0x8000..=0xFFFF => { + let bank = mirror_bank(self.prg_bank(addr), self.prg_rom.len() / PRG_BANK_8K); + self.prg_rom[bank * PRG_BANK_8K + (usize::from(addr) & 0x1FFF)] + } + 0x6000..=0x7FFF if !self.wram.is_empty() && self.core.prg_ram_enabled() => { + self.wram[usize::from(addr - 0x6000) % self.wram.len()] + } + _ => 0, + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match (self.board, addr) { + (_, 0x8000..=0xFFFF) => self.write_high(addr, value), + (ExtBoard::M114 { .. }, 0x6000..=0x7FFF) => { + // Not on the WRAM interface: taken whatever `$A001` says. + if addr & 0xE001 == 0x6000 { + self.regs[0] = value; + } else { + self.regs[1] = value; + } + if !self.wram.is_empty() && self.core.prg_ram_writable() { + let len = self.wram.len(); + self.wram[usize::from(addr - 0x6000) % len] = value; + } + } + _ => {} + } + } + + fn chr_phys(&self, addr: u16) -> Option { + if self.chr_is_ram { + None + } else { + u32::try_from(self.chr_offset(addr)).ok() + } + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr < 0x2000 { + self.chr[self.chr_offset(addr)] + } else { + self.core.ppu_read(addr) + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + if addr >= 0x2000 { + self.core.ppu_write(addr, value); + } else if self.chr_is_ram { + let off = self.chr_offset(addr); + self.chr[off] = value; + } + } + + fn nametable_address(&self, addr: u16) -> u16 { + self.core.nametable_address(addr) + } + + fn current_mirroring(&self) -> Mirroring { + self.core.current_mirroring() + } + + fn notify_a12(&mut self, level: bool) { + self.core.notify_a12(level); + } + + fn notify_a12_at_sub_dot(&mut self, level: bool, sub_dot: u8) { + self.core.notify_a12_at_sub_dot(level, sub_dot); + } + + fn notify_cpu_cycle(&mut self) { + self.core.notify_cpu_cycle(); + } + + fn irq_pending(&self) -> bool { + self.core.irq_pending() + } + + fn irq_acknowledge(&mut self) { + self.core.irq_acknowledge(); + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = self.core.debug_info(); + info.mapper_id = self.board.id(); + info.name = self.board.name().to_string(); + for (i, r) in self.regs.iter().enumerate() { + info.extra.push((format!("board{i}"), format!("{r:#04x}"))); + } + info + } + + fn save_state(&self) -> Vec { + let core = self.core.save_state(); + let chr = if self.chr_is_ram { self.chr.len() } else { 0 }; + let mut out = Vec::with_capacity(HEAD + core.len() + chr + self.wram.len()); + out.push(SAVE_STATE_VERSION); + out.push(self.board.id() as u8); + out.push(self.board.state_tag()); + out.extend_from_slice(&self.regs); + out.push(0); // Reserved for a board latch byte. + out.extend_from_slice(&(core.len() as u32).to_le_bytes()); + out.extend_from_slice(&core); + if self.chr_is_ram { + out.extend_from_slice(&self.chr); + } + out.extend_from_slice(&self.wram); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + if data.len() < HEAD { + return Err(MapperError::WrongLength { + expected: HEAD, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + if u16::from(data[1]) != self.board.id() & 0xFF || data[2] != self.board.state_tag() { + return Err(MapperError::Invalid(format!( + "state is for mapper {} variant {}, this board is {}", + data[1], + data[2], + self.board.name() + ))); + } + let core_len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize; + let chr = if self.chr_is_ram { self.chr.len() } else { 0 }; + // Checked: `core_len` comes from the blob, and a plain sum can wrap on + // a 32-bit target, pass the check, and panic at the slice. + let expected = HEAD + .checked_add(core_len) + .and_then(|n| n.checked_add(chr + self.wram.len())); + if expected != Some(data.len()) { + return Err(MapperError::WrongLength { + expected: expected.unwrap_or(usize::MAX), + got: data.len(), + }); + } + let mut cur = HEAD; + self.core.load_state(&data[cur..cur + core_len])?; + cur += core_len; + self.regs.copy_from_slice(&data[3..7]); + if self.chr_is_ram { + self.chr.copy_from_slice(&data[cur..cur + chr]); + cur += chr; + } + self.wram.copy_from_slice(&data[cur..]); + Ok(()) + } +} + +#[cfg(test)] +#[path = "mmc3_boards_ext_tests.rs"] +mod tests; diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs new file mode 100644 index 00000000..f335fb9e --- /dev/null +++ b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Register-decode tests for `mmc3_boards_ext.rs`. + +use alloc::vec::Vec; + +/// A NES 2.0 image for `mapper`.`sub`: every 8 KiB PRG bank and every 1 KiB +/// CHR bank starts with its own bank number (low, high). +fn nes2_image(mapper: u16, sub: u8, prg_16k: u8, chr_8k: u8) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"NES\x1A"); + v.push(prg_16k); + v.push(chr_8k); + v.push(((mapper & 0x0F) << 4) as u8); + v.push((mapper & 0xF0) as u8 | 0x08); + v.push(((mapper >> 8) & 0x0F) as u8 | (sub << 4)); + v.extend_from_slice(&[0u8; 7]); + for b in 0..usize::from(prg_16k) * 2 { + let mut bank = alloc::vec![0u8; 0x2000]; + bank[0] = b as u8; + bank[1] = (b >> 8) as u8; + v.extend_from_slice(&bank); + } + for b in 0..usize::from(chr_8k) * 8 { + let mut bank = alloc::vec![0u8; 0x400]; + bank[0] = b as u8; + bank[1] = (b >> 8) as u8; + v.extend_from_slice(&bank); + } + v +} + +#[test] +fn m114_and_m182_parse() { + for (id, sub) in [(114u16, 0u8), (114, 1), (182, 0)] { + assert!( + crate::parse(&nes2_image(id, sub, 16, 32)).is_ok(), + "mapper {id}.{sub} must construct" + ); + } +} + +use super::*; + +fn prg_image(banks_8k: usize) -> Box<[u8]> { + let mut v = vec![0u8; banks_8k * PRG_BANK_8K]; + for b in 0..banks_8k { + v[b * PRG_BANK_8K] = b as u8; + v[b * PRG_BANK_8K + 1] = (b >> 8) as u8; + } + v.into_boxed_slice() +} + +fn chr_image(banks_1k: usize) -> Box<[u8]> { + let mut v = vec![0u8; banks_1k * CHR_BANK_1K]; + for b in 0..banks_1k { + v[b * CHR_BANK_1K] = b as u8; + v[b * CHR_BANK_1K + 1] = (b >> 8) as u8; + } + v.into_boxed_slice() +} + +fn board(kind: ExtBoard, prg_banks: usize, chr_banks: usize) -> Mmc3BoardExt { + Mmc3BoardExt::new( + kind, + prg_image(prg_banks), + chr_image(chr_banks), + Mirroring::Vertical, + 0, + ) + .expect("valid sizes") +} + +fn prg_at(m: &mut Mmc3BoardExt, addr: u16) -> usize { + m.cpu_read(addr) as usize | (m.cpu_read(addr + 1) as usize) << 8 +} + +fn chr_at(m: &mut Mmc3BoardExt, addr: u16) -> usize { + m.ppu_read(addr) as usize | (m.ppu_read(addr + 1) as usize) << 8 +} + +/// One filtered A12 rise (the MMC3 needs three M2 cycles of A12 low). +fn clock_a12(m: &mut Mmc3BoardExt) { + m.notify_a12(false); + for _ in 0..4 { + m.notify_cpu_cycle(); + } + m.notify_a12(true); + // The MMC3 core asserts a counter-zero IRQ one CPU cycle after the + // rise (`irq_assert_pending_next_cycle`); let that cycle pass. + m.notify_cpu_cycle(); +} + +const M114_0: ExtBoard = ExtBoard::M114 { sub1: false }; +const M114_1: ExtBoard = ExtBoard::M114 { sub1: true }; + +// --------------------------------------------------------------------------- +// Mapper 114 (`INES_Mapper_114.md`). +// --------------------------------------------------------------------------- + +/// Submapper 0: bank select is written at `$A000`, bank data at `$C000`, and +/// the written index `w` means MMC3 register `0 3 1 5 6 7 2 4`[w]. Each case +/// is a literal row of the page's table, not a lookup in the code's. +#[test] +fn m114_sub0_index_and_address_scramble_follow_the_page() { + // (written index, MMC3 register meant, where to observe it) + for (written, reg, window, is_prg) in [ + (4u8, 6u8, 0x8000u16, true), + (5, 7, 0xA000, true), + (0, 0, 0x0000, false), + (2, 1, 0x0800, false), + (6, 2, 0x1000, false), + (1, 3, 0x1400, false), + (7, 4, 0x1800, false), + (3, 5, 0x1C00, false), + ] { + let mut m = board(M114_0, 32, 256); + m.cpu_write(0xA000, written); // real $8000 + m.cpu_write(0xC000, 0x12); // real $8001 + let got = if is_prg { + prg_at(&mut m, window) + } else { + chr_at(&mut m, window) + }; + assert_eq!(got, 0x12, "written index {written} must reach R{reg}"); + // The plain MMC3 addresses do not select banks on this board: + // `$8000` is `$A001` and `$8001` is `$A000` (mirroring). + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x03); + assert_eq!( + m.current_mirroring(), + Mirroring::Horizontal, + "$8001 is $A000" + ); + } +} + +/// Submapper 0: the bank-select mode bits (6 and 7) pass through unchanged, +/// so PRG mode 1 moves R6 to `$C000` and CHR mode 1 moves R2 to `$0000`. +#[test] +fn m114_sub0_mode_bits_are_not_scrambled() { + let mut m = board(M114_0, 32, 256); + m.cpu_write(0xA000, 0x40 | 4); // PRG mode 1, R6 + m.cpu_write(0xC000, 0x09); + assert_eq!(prg_at(&mut m, 0xC000), 9); + assert_eq!(prg_at(&mut m, 0x8000), 30, "second-last bank at $8000"); + m.cpu_write(0xA000, 0x80 | 6); // CHR mode 1, R2 + m.cpu_write(0xC000, 0x21); + assert_eq!(chr_at(&mut m, 0x0000), 0x21); +} + +/// Submapper 0 IRQ: latch at `$A001` (real `$C000`), reload at `$C001`, +/// disable/enable at `$E000`/`$E001` (unscrambled). The MMC3A behaviour the +/// page names: a latch of 0 never raises the IRQ. +#[test] +fn m114_sub0_irq_registers_and_mmc3a_zero_latch() { + let mut m = board(M114_0, 32, 256); + m.cpu_write(0xA001, 2); // latch + m.cpu_write(0xC001, 0); // reload + m.cpu_write(0xE001, 0); // enable + clock_a12(&mut m); // reload -> 2 + clock_a12(&mut m); // 1 + assert!(!m.irq_pending()); + clock_a12(&mut m); // 0 -> IRQ + assert!(m.irq_pending(), "latch 2 fires on the third clock"); + m.cpu_write(0xE000, 0); // disable + acknowledge + assert!(!m.irq_pending()); + + let mut m = board(M114_0, 32, 256); + m.cpu_write(0xA001, 0); + m.cpu_write(0xC001, 0); + m.cpu_write(0xE001, 0); + // The `$C001` reload itself raises one IRQ on both revisions + // (`MMC3.md`: the alternate revision checks the 1->0 transition + // "whether from decrementing or reloading"); acknowledge it. + clock_a12(&mut m); + m.cpu_write(0xE000, 0); + m.cpu_write(0xE001, 0); + for _ in 0..8 { + clock_a12(&mut m); + } + assert!(!m.irq_pending(), "MMC3A: a latch of 0 disables the IRQ"); +} + +/// Submapper 1: `$8000`→`$A001`, `$8001`→`$8001`, `$A000`→`$8000`, +/// `$A001`→`$C001`, `$C000`→`$A000`, `$C001`→`$C000`; written index `w` +/// means `0 2 5 3 6 1 7 4`[w]. +#[test] +fn m114_sub1_scramble_follows_the_page() { + for (written, window, is_prg) in [ + (4u8, 0x8000u16, true), // R6 + (6, 0xA000, true), // R7 + (0, 0x0000, false), // R0 + (5, 0x0800, false), // R1 + (1, 0x1000, false), // R2 + (3, 0x1400, false), // R3 + (7, 0x1800, false), // R4 + (2, 0x1C00, false), // R5 + ] { + let mut m = board(M114_1, 32, 256); + m.cpu_write(0xA000, written); + m.cpu_write(0x8001, 0x0A); + let got = if is_prg { + prg_at(&mut m, window) + } else { + chr_at(&mut m, window) + }; + assert_eq!(got, 0x0A, "written index {written} at {window:#06x}"); + } + let mut m = board(M114_1, 32, 256); + m.cpu_write(0xC000, 1); + assert_eq!( + m.current_mirroring(), + Mirroring::Horizontal, + "$C000 is $A000" + ); + // IRQ latch at `$C001` (real `$C000`), reload at `$A001` (real `$C001`). + m.cpu_write(0xC001, 1); + m.cpu_write(0xA001, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + clock_a12(&mut m); + assert!(m.irq_pending(), "latch 1 fires on the second clock"); +} + +/// `$6000` `M.S. BBBb`: with M set, a 16 KiB bank replaces both halves +/// (NROM-128); with S also set, CPU A14 replaces bit 0 (NROM-256). +#[test] +fn m114_nrom_override() { + let mut m = board(M114_0, 32, 256); + m.cpu_write(0x6000, 0x80 | 0x03); + for (addr, bank) in [(0x8000, 6), (0xA000, 7), (0xC000, 6), (0xE000, 7)] { + assert_eq!(prg_at(&mut m, addr), bank, "NROM-128 at {addr:#06x}"); + } + m.cpu_write(0x6000, 0xA0 | 0x03); + for (addr, bank) in [(0x8000, 4), (0xA000, 5), (0xC000, 6), (0xE000, 7)] { + assert_eq!(prg_at(&mut m, addr), bank, "NROM-256 at {addr:#06x}"); + } + m.cpu_write(0x6000, 0x23); // M clear: MMC3 banks, bits 0-3/5 ignored. + assert_eq!(prg_at(&mut m, 0xE000), 31); + assert_eq!(prg_at(&mut m, 0xC000), 30); +} + +/// `$6001` bit 0 is CHR A18, and both `$600x` registers work with the +/// MMC3's WRAM disabled ("not connected to the MMC3 clone's WRAM +/// interface"). +#[test] +fn m114_outer_chr_bank_ignores_the_wram_bits() { + let mut m = board(M114_0, 32, 512); + m.cpu_write(0x8000, 0x00); // real $A001: WRAM disabled + m.cpu_write(0xA000, 6); // R2 + m.cpu_write(0xC000, 0x05); + m.cpu_write(0x6001, 0x01); + assert_eq!(chr_at(&mut m, 0x1000), 0x105); + m.cpu_write(0x6001, 0x00); + assert_eq!(chr_at(&mut m, 0x1000), 0x005); + m.cpu_write(0x6000, 0x80); + assert_eq!(prg_at(&mut m, 0xC000), 0, "$6000 taken with WRAM disabled"); +} + +/// 182 is dispatched as 114 submapper 0; 114 submapper 1 is its own variant. +#[test] +fn m182_is_m114_submapper_0() { + let (_, m) = crate::parse(&nes2_image(182, 0, 16, 32)).expect("182"); + assert_eq!(m.debug_info().mapper_id, 114); + assert!(m.debug_info().name.contains("114.0")); + let (_, m) = crate::parse(&nes2_image(114, 1, 16, 32)).expect("114.1"); + assert!(m.debug_info().name.contains("114.1")); +} + +/// The state round-trips, and a state for one scramble variant does not load +/// onto the other. +#[test] +fn m114_state_round_trip_and_variant_refusal() { + let mut m = board(M114_0, 32, 256); + m.cpu_write(0x6000, 0xA5); + m.cpu_write(0x6001, 0x01); + m.cpu_write(0xA000, 4); + m.cpu_write(0xC000, 0x07); + let blob = m.save_state(); + let mut n = board(M114_0, 32, 256); + n.load_state(&blob).expect("same variant"); + assert_eq!(n.save_state(), blob); + assert!(board(M114_1, 32, 256).load_state(&blob).is_err()); + let mut bad = blob.clone(); + bad[8..12].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(board(M114_0, 32, 256).load_state(&bad).is_err()); +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index e46b5354..fcd3eafb 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -132,7 +132,14 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // GTROM (111), promoted from BestEffort once the board matched its // page (register window, bonus RAM, self-flashing) and a CC0 fixture // pinned it. None has a redistributable commercial ROM. - 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => { + // + // v3.2.0 adds MMC3-derived boards written from their NESdev pages + // (`mmc3_boards_ext.rs`), each Curated on a D23 record in + // `docs/compatibility.md`: a local dump that boots to its title, named + // by title and `Nes::rom_sha256`, pinned by a boot snapshot in + // `external_extended.rs` and a committed screenshot. 182 is a + // duplicate of 114 (the 114 page) and shares its model. + 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 | 114 | 182 => { Some(MapperTier::Curated) } @@ -243,6 +250,8 @@ mod tests { 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, + // v3.2.0 (`mmc3_boards_ext.rs`). + 114, 182, ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 83b1bb3f..b838aaca 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1418,3 +1418,27 @@ fn extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu() { "extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu", ); } + +// ============================================================ +// v3.2.0 — MMC3-derived families written from their NESdev pages +// (`mmc3_boards_ext.rs`). One GoodNES dump per family, byte-identity boot +// snapshot; the D23 records are in `docs/compatibility.md`. +// ============================================================ + +#[test] +fn extended_m114_aladdin_supergame() { + check( + "mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].zip", + DEFAULT_IDLE, + "extended_m114_aladdin_supergame", + ); +} + +#[test] +fn extended_m182_super_donkey_kong() { + check( + "mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m182_super_donkey_kong", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_aladdin_supergame.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_aladdin_supergame.snap new file mode 100644 index 00000000..c6b125bc --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_aladdin_supergame.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].zip +rom_sha256=e1c304e4be8ea9048609df75a6435cd4448dca941e07be7608c9dd5b8e6004e1 +frames=600 +fb_bytes=245760 +cycles=17838520 +audio_samples=439524 +audio_fnv1a64=f5849832e1419582 +checkpoint f600 fb_fnv1a64=ded9d8b53ef12b06 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m182_super_donkey_kong.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m182_super_donkey_kong.snap new file mode 100644 index 00000000..f9f0bbdd --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m182_super_donkey_kong.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].zip +rom_sha256=2c32f5a11213285d24acb39f8ffd85d56b6f6a1bba2673c8ad3e474f44c1d12d +frames=600 +fb_bytes=245760 +cycles=17838532 +audio_samples=439524 +audio_fnv1a64=a1ff96fb65e243d0 +checkpoint f600 fb_fnv1a64=d008bdc2e1a64a88 diff --git a/docs/compatibility.md b/docs/compatibility.md index 73137556..bea86177 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -362,3 +362,16 @@ FE-11, which the v3.1 → v4.0 line plan does not schedule: - **CRC32 vs. SHA-256 for ROM identification.** We use SHA-256 for save state directory naming (lower collision risk). For ROM compatibility databases, CRC32 is the community standard; we may add it as a secondary key. - **Region override.** Some users want to play PAL versions of NTSC games at NTSC speed. Plan: expose a region override in the settings UI; warn that this may break timing-sensitive ROMs. + +## Curated evidence from local dumps (D23) + +A local dump counts as Curated evidence when its record names the title, the +SHA-256 of the bytes after the 16-byte header (`Nes::rom_sha256`), the frame +count of the boot snapshot in `external_extended.rs`, and a committed +screenshot of that frame. The dumps themselves stay in the gitignored +`tests/roms/external/` tree. + +| Mapper | Title | SHA-256 after the header (`Nes::rom_sha256`) | Frames | Screenshot | +|--------|-------|----------------------------------------------|--------|------------| +| 114 | Aladdin (SuperGame) (Mapper 114) [!] | `5489244538c94653f766bd80f07b2aa684ad139bddea68f40b60a0473c9286e2` | 600 | `screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png` | +| 182 (as 114.0) | Super Donkey Kong (Unl) [!] | `67d06173c4b8239d46c84335591216b1adfa38344107405ec88c58327217a361` | 600 | `screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index a93c5a69..3fcfaeda 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -838,6 +838,23 @@ path), were all CAUGHT by the unit tests or `roster_boards.rs`. Two needed a new test first: the bus's floating-read call, which no mapper-level test can reach, and the PPU's unfolded `$3xxx` path. The record is in the v2.9.6 plan. +### v3.2.0 — MMC3-derived and multi-mode boards (`mmc3_boards_ext.rs`) + +Written from the vendored NESdev pages named in the table; no +reference-emulator source was read. The boards live in a new module, +`mmc3_boards_ext.rs`, because their board logic sits in FRONT of the MMC3 (a +register address and index scramble) or rewires the PPU address lines the MMC3 +sees, where the `mmc3_boards.rs` boards only post-process its bank outputs. The +MMC3 is still the project's own `Mmc3` used as a register file and IRQ counter. +A family is **Curated** on a D23 record (`docs/compatibility.md`, "Curated +evidence from local dumps"): a local dump that boots to its title, pinned by a +boot snapshot in `external_extended.rs` and a committed screenshot. + +| iNES | Sub | Board (page) | Tier | IRQ | Notes | +|------|-----|--------------|------|-----|-------| +| 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. | +| 182 | — | (`INES_Mapper_182` → `INES_Mapper_114`) | Curated | as 114 | A duplicate of 114: the 182 page is a redirect, and the 114 page says Hosenkan's 182 games write 0 to `$6000`/`$6001` once and never again. Dispatched as 114 submapper 0; the full 114 model is kept rather than the "no `$600x`" simplification, since the zero writes leave it in the MMC3's own banking. | + ### Mapper accuracy tiering (v1.2.0) Every supported family is classified `Core` / `Curated` / `BestEffort` by diff --git a/screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png b/screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..43cec64ac6f5c9054b3ac1e3e299c4cf91c1b7d5 GIT binary patch literal 8277 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png b/screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..a3dd69ed5b8ae60fe921e135c03450f8a44f3180 GIT binary patch literal 4351 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 Date: Thu, 8 Oct 2026 19:50:06 -0400 Subject: [PATCH 005/104] test(mappers): boot Boogerman as mapper 114 submapper 1 Mapper 114 submapper 1 (Boogerman's scrambling pattern) was pinned by unit tests only in the previous commit, because no dump carries a submapper-1 header. This adds a real-dump boot for it. GoodNES v3.23b labels "Boogerman (Sugar Softec) (Unl) [!]" mapper 215. INES_Mapper_215.md explains that label: "Boogerman (INES Mapper 114 Submapper 1) was originally assigned by CaH4e3 to Mapper 215; he later reassigned UNL-8237 to Mapper 215 instead." Measured: loaded as 215 the dump never writes $5007 (the debug view shows the pattern register at 0 after 600 frames) and the screen is a field of grey dashes, i.e. it is not a 215 board. Loaded as 114.1 it boots to its title (the slime BOOGERMAN logo, the START sign and Boogerman on his toilet). The staged copy (gitignored, tests/roms/external/mapper-114-SuperGame/) is the GoodNES dump with its header rewritten to NES 2.0 mapper 114 submapper 1. Nes::rom_sha256 hashes the bytes after the header, so the D23 record names the dump itself: ba2d65f240d8f97c7b77973042d8141cf15116ffc3d73caed450c21e08fd54a2. Verification - external_extended.rs: extended_m114_1_boogerman, 600 frames idle, blessed and re-run clean; screenshot committed; D23 row added. - Mutations (each restored from a copy): 1. 114.1 address table with the $C000/$C001 targets swapped (mirroring <-> IRQ latch): caught by the unit test ("$C000 is $A000", left Vertical right Horizontal); NOT caught by the boot snapshot, which never exercises that pair by frame 600. 2. 114.1 index table entries 1 and 2 swapped: caught by the unit test ("written index 1 at 0x1000", left 0 right 10) and by the boot snapshot (f600 framebuffer hash 7d9aa1a3... -> 320713d2...). Not verified: nothing beyond the title screen is exercised. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../tests/external_extended.rs | 15 +++++++++++++++ ...rnal_extended__extended_m114_1_boogerman.snap | 12 ++++++++++++ docs/compatibility.md | 1 + docs/mappers.md | 2 +- ... (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png | Bin 0 -> 8432 bytes 5 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_1_boogerman.snap create mode 100644 screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index b838aaca..7eabc395 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1434,6 +1434,21 @@ fn extended_m114_aladdin_supergame() { ); } +/// Submapper 1's scramble. GoodNES labels this dump mapper 215, the +/// assignment `INES_Mapper_215.md` says was later withdrawn ("Boogerman +/// (Mapper 114 Submapper 1) was originally assigned by CaH4e3 to Mapper +/// 215"); it never writes `$5007`, so it does not boot as 215. The staged +/// copy carries a NES 2.0 header naming 114.1; the bytes after the header, +/// and so `Nes::rom_sha256`, are the GoodNES dump's. +#[test] +fn extended_m114_1_boogerman() { + check( + "mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).nes", + DEFAULT_IDLE, + "extended_m114_1_boogerman", + ); +} + #[test] fn extended_m182_super_donkey_kong() { check( diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_1_boogerman.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_1_boogerman.snap new file mode 100644 index 00000000..bbbf1be5 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m114_1_boogerman.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).nes +rom_sha256=26d1615d17add5331cb75387e1af40953cfb2ba2712ef93b6f88d063b5177e1c +frames=600 +fb_bytes=245760 +cycles=17838528 +audio_samples=439524 +audio_fnv1a64=10ac47d12955ad63 +checkpoint f600 fb_fnv1a64=7d9aa1a3b7991b5f diff --git a/docs/compatibility.md b/docs/compatibility.md index bea86177..22e35458 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -374,4 +374,5 @@ screenshot of that frame. The dumps themselves stay in the gitignored | Mapper | Title | SHA-256 after the header (`Nes::rom_sha256`) | Frames | Screenshot | |--------|-------|----------------------------------------------|--------|------------| | 114 | Aladdin (SuperGame) (Mapper 114) [!] | `5489244538c94653f766bd80f07b2aa684ad139bddea68f40b60a0473c9286e2` | 600 | `screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png` | +| 114.1 | Boogerman (Sugar Softec) (Unl) [!] (GoodNES labels it 215; staged with a NES 2.0 114.1 header) | `ba2d65f240d8f97c7b77973042d8141cf15116ffc3d73caed450c21e08fd54a2` | 600 | `screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png` | | 182 (as 114.0) | Super Donkey Kong (Unl) [!] | `67d06173c4b8239d46c84335591216b1adfa38344107405ec88c58327217a361` | 600 | `screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index 3fcfaeda..5c89414b 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -852,7 +852,7 @@ boot snapshot in `external_extended.rs` and a committed screenshot. | iNES | Sub | Board (page) | Tier | IRQ | Notes | |------|-----|--------------|------|-----|-------| -| 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. | +| 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. GoodNES labels *Boogerman* (Sugar Softec) mapper 215, the assignment the 215 page says was withdrawn; it never writes `$5007` and boots only as 114.1, so its D23 record uses a copy with a NES 2.0 114.1 header (`Nes::rom_sha256` excludes the header, so the identity is the dump's). | | 182 | — | (`INES_Mapper_182` → `INES_Mapper_114`) | Curated | as 114 | A duplicate of 114: the 182 page is a redirect, and the 114 page says Hosenkan's 182 games write 0 to `$6000`/`$6001` once and never again. Dispatched as 114 submapper 0; the full 114 model is kept rather than the "no `$600x`" simplification, since the zero writes leave it in the MMC3's own banking. | ### Mapper accuracy tiering (v1.2.0) diff --git a/screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png b/screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png new file mode 100644 index 0000000000000000000000000000000000000000..973961394ad309300d6dd782fdc61c1f5d9caa6d GIT binary patch literal 8432 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 Date: Thu, 8 Oct 2026 20:19:17 -0400 Subject: [PATCH 006/104] feat(mappers): add mapper 215 (Sugar Softec UNL-8237 / UNL-8237A) Mapper 215 is Sugar Softec's later MMC3 board: mapper 114's scramble made selectable, with the board registers moved to $5000-$5FFF. Written from the vendored INES_Mapper_215.md and MMC3.md, into the same mmc3_boards_ext.rs as 114; no reference-emulator source was read. What the board does, per the page - $5007 (mask $F007, power-up $00) selects one of eight scramble patterns. 0, 2, 5, 6 and 7 are the identity; 1, 3 and 4 permute the MMC3 register addresses and the $8000 index. Both of the page's tables are transcribed row by row into M215_SCRAMBLE and applied by the same unscramble() as 114 (bank-select bits 3-7 pass through, and $8001 / $A000-$FFFF data is never scrambled). - $5000 MCS. BBBb (power-up $00): M/S/BBBb are 114's NROM override; C is the 128 KiB mode, replacing PRG A17 and CHR A17 with $5001 bits 4 and 5. - $5001 (power-up $xF, taken as $0F): the outer bank. Submapper 0 (UNL-8237): PRG A18-A19 = bits 0-1, CHR A18-A19 = bits 2-3. Submapper 1 (UNL-8237A): PRG A18/A19/A20 = bits 0/1/3, CHR A18-A20 = bits 1-3. A console reset restores it (the page: reset "detected via an interruption of the M2 signal"); $5000 and $5007 are kept. - IRQ: the page names no revision, only that the unscrambled registers "function the same as the MMC3's", so the default (Sharp) is used. Ambiguities and how they were resolved - The order of the NROM override and the 128 KiB A17 substitution is not stated. C is applied last, to whichever bank is in force, since the page describes it as substituting the PRG A17 line both drive. - Pattern 1's index table is the inverse of mapper 114 submapper 0's table, while its address table is identical to it. Checked black-box rather than assumed to be a transcription error: under the table as printed the Super 808 menu (which sets $5007 = 1) runs, and Aladdin selected from it runs and draws its intro artwork. So both pages are taken as printed. - The "alternative outer bank registers in the $6xxx range" some multicarts carry are not modelled (the page gives no layout). The tier arm: v3.2.0's ids move to an arm of their own (114, 182, 215) with #[allow(clippy::match_same_arms)], so the batch keeps its rationale beside its ids; the previous commit had appended them to v2.9.6's arm. Verification - Red first: a parse test of 215.0 / 215.1 failed with the id unsupported, then passed. - 8 unit tests: literal cells of both tables for every non-identity pattern and the identity ones; pattern 3 moving the IRQ registers and pattern 1 moving mirroring; Sharp IRQ; the $5001 power-up value, its restoration on reset and $5000/$5007 surviving it; the NROM override through a $F007-masked mirror ($5FF8) and the 128 KiB mode for PRG and CHR A17; both outer-bank layouts; state round trip and variant refusal. - Boot snapshots, 600 frames idle: Mortal Kombat 3 Extra 60 (Unl) [!] (pattern 4) boots to its EXTRA 60 title with the 1/2 PLAYER menu; 2-in-1 Super 808 (Unl) [p1][!] (pattern 1) boots to its menu (THE ALADDIN / THE EARTH WORM). Screenshots and D23 rows committed. Of the other staged 215 dumps, MK3 (SuperGame) [a1] (pattern 3) reaches its title and the King005 2-in-1, Super 308 3-in-1 and SPC005 5-in-1 reach their menus (probe only, not snapshotted). - Tier: Curated on the D23 records. - Mutation: pattern 4's index table with written 4 and 5 swapped. Caught by the unit test ("pattern 4, written index 4") and by the MK3 Extra 60 boot snapshot (f600 hash 3752b851... -> e877f1c7...); the Super 808 snapshot (pattern 1) is unaffected, as it should be. Restored from a copy. Not verified - Submapper 1 (UNL-8237A, the 9-in-1 king001) has no local dump; its outer-bank layout is unit-tested only. - Nothing past the title/menu screens is snapshotted. - The Super 808 menu shows a stray 8x8 sprite at the top-left corner; whether hardware shows it too is not known. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 9 + .../rustynes-mappers/src/mmc3_boards_ext.rs | 144 +++++++++++++- .../src/mmc3_boards_ext_tests.rs | 179 ++++++++++++++++++ crates/rustynes-mappers/src/tier.rs | 17 +- .../tests/external_extended.rs | 20 ++ ...xtended_m215_mortal_kombat_3_extra_60.snap | 12 ++ ...nal_extended__extended_m215_super_808.snap | 12 ++ docs/compatibility.md | 2 + docs/mappers.md | 1 + .../2-in-1 Super 808 (Unl) [p1][!].png | Bin 0 -> 1759 bytes .../Mortal Kombat 3 Extra 60 (Unl) [!].png | Bin 0 -> 5898 bytes 11 files changed, 387 insertions(+), 9 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_mortal_kombat_3_extra_60.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_super_808.snap create mode 100644 screenshots/external/mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].png create mode 100644 screenshots/external/mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index f78d97c7..5afea0fe 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1411,6 +1411,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { &h, )?, 182 => mmc3_ext(Mmc3ExtBoardKind::M114 { sub1: false }, prg_rom, chr_rom, &h)?, + // Sugar Softec UNL-8237 (215.0) / UNL-8237A (215.1). + 215 => mmc3_ext( + Mmc3ExtBoardKind::M215 { + sub1: h.submapper == 1, + }, + prg_rom, + chr_rom, + &h, + )?, 44 => Box::new( new_m44(prg_rom, chr_rom, h.mirroring) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext.rs b/crates/rustynes-mappers/src/mmc3_boards_ext.rs index 30f7ee4d..df93ec65 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! MMC3-clone boards added in v3.2.0, written from their NESdev pages: -//! mappers 114 and 182 (the SuperGame / Hosenkan register scramblers). +//! mappers 114 and 182 (the SuperGame / Hosenkan register scramblers) and +//! 215 (Sugar Softec's selectable scrambler, UNL-8237 / UNL-8237A). //! //! These are, like the boards in `mmc3_boards.rs`, a stock MMC3 with a //! board-specific layer in front of it or behind it. The difference that @@ -49,6 +50,29 @@ //! zero writes are harmless under the full 114 model (NROM override off, CHR //! A18 = 0), so there is no reason to model the "no `$600x` registers" //! simplification some implementations use. +//! +//! ## Mapper 215 (`INES_Mapper_215.md`) +//! +//! Sugar Softec's later boards: the 114 idea with the scramble made +//! selectable and the board registers moved to `$5000-$5FFF` (mask `$F007`): +//! +//! - `$5000` `MCS. BBBb` (power-up `$00`): `M` and `S`/`BBBb` are 114's NROM +//! override; `C` selects 128 KiB outer banks, replacing PRG A17 and CHR A17 +//! with `$5001` bits 4 and 5. +//! - `$5001` (power-up `$xF`, and reset to it on a console reset): the outer +//! bank. Submapper 0 (UNL-8237): PRG A18-A19 = bits 0-1, CHR A18-A19 = +//! bits 2-3. Submapper 1 (UNL-8237A): PRG A18/A19/A20 = bits 0/1/3, CHR +//! A18/A19/A20 = bits 1/2/3. +//! - `$5007` (power-up `$00`): the scramble pattern, 0-7. Patterns 0, 2, 5, 6 +//! and 7 are the identity; 1, 3 and 4 permute the MMC3 register addresses +//! and the `$8000` index per the page's two tables ([`M215_SCRAMBLE`]). +//! +//! The page gives no IRQ revision, saying only that the unscrambled registers +//! "function the same as the MMC3's", so the default (Sharp) behaviour is used. +//! Where the page leaves the NROM override and the 128 KiB mode's A17 +//! substitution unordered, `C` is applied last, to whichever bank (MMC3 or +//! NROM) is in force: it is described as a substitution of the PRG A17 line, +//! which both sources drive. #![allow( clippy::cast_possible_truncation, @@ -83,12 +107,19 @@ pub enum ExtBoard { /// NES 2.0 submapper 1. sub1: bool, }, + /// Mapper 215, `INES_Mapper_215.md`. `sub1` selects the UNL-8237A outer + /// bank layout (PRG and CHR A20). + M215 { + /// NES 2.0 submapper 1 (UNL-8237A). + sub1: bool, + }, } impl ExtBoard { const fn id(self) -> u16 { match self { Self::M114 { .. } => 114, + Self::M215 { .. } => 215, } } @@ -96,6 +127,8 @@ impl ExtBoard { match self { Self::M114 { sub1: false } => "SuperGame MMC3 scramble (114.0)", Self::M114 { sub1: true } => "SuperGame MMC3 scramble (114.1)", + Self::M215 { sub1: false } => "Sugar Softec UNL-8237 (215.0)", + Self::M215 { sub1: true } => "Sugar Softec UNL-8237A (215.1)", } } @@ -105,6 +138,8 @@ impl ExtBoard { match self { Self::M114 { sub1: false } => 0, Self::M114 { sub1: true } => 1, + Self::M215 { sub1: false } => 2, + Self::M215 { sub1: true } => 3, } } } @@ -134,6 +169,51 @@ const M114_SUB1: Scramble = Scramble { index: [0, 2, 5, 3, 6, 1, 7, 4], }; +/// The identity scramble (mapper 215 patterns 0, 2, 5, 6 and 7). +const IDENTITY: Scramble = Scramble { + addr: [ + 0x8000, 0x8001, 0xA000, 0xA001, 0xC000, 0xC001, 0xE000, 0xE001, + ], + index: [0, 1, 2, 3, 4, 5, 6, 7], +}; + +/// Mapper 215's eight patterns, selected by `$5007`, transcribed from the +/// page's address table (row = `$5007`, columns `$8000 ... $E001` written) +/// and index table (row = `$5007`, column = the value written). +const M215_SCRAMBLE: [Scramble; 8] = [ + IDENTITY, + Scramble { + addr: [ + 0xA001, 0xA000, 0x8000, 0xC000, 0x8001, 0xC001, 0xE000, 0xE001, + ], + index: [0, 2, 6, 1, 7, 3, 4, 5], + }, + Scramble { + addr: IDENTITY.addr, + index: [0, 5, 4, 1, 7, 2, 6, 3], + }, + Scramble { + addr: [ + 0xC001, 0x8000, 0x8001, 0xA000, 0xA001, 0xE001, 0xE000, 0xC000, + ], + index: [0, 6, 3, 7, 5, 2, 4, 1], + }, + Scramble { + addr: [ + 0xA001, 0x8001, 0x8000, 0xC000, 0xA000, 0xC001, 0xE000, 0xE001, + ], + index: [0, 2, 5, 3, 6, 1, 7, 4], + }, + IDENTITY, + IDENTITY, + IDENTITY, +]; + +/// Mapper 215's `$5001` power-up value, `$xF`: the last outer bank, so the +/// MMC3's fixed `$E000` bank lands on the image's reset vector. The page +/// leaves the high nibble undefined; it is taken as 0. +const M215_OUTER_POWER_UP: u8 = 0x0F; + /// The position of an `$E001`-masked MMC3 address in a [`Scramble`] row. const fn scramble_slot(addr: u16) -> usize { ((((addr >> 13) & 3) << 1) | (addr & 1)) as usize @@ -179,6 +259,7 @@ pub struct Mmc3BoardExt { /// Work RAM at `$6000-$7FFF`, only when a NES 2.0 header declares it. wram: Box<[u8]>, /// Board registers. 114: `regs[0]` = `$6000`, `regs[1]` = `$6001`. + /// 215: `regs[0]` = `$5000`, `regs[1]` = `$5001`, `regs[2]` = `$5007`. regs: [u8; 4], } @@ -219,7 +300,12 @@ impl Mmc3BoardExt { // of zero disables the IRQ, on which Aladdin depends." let revision = match board { ExtBoard::M114 { .. } => Mmc3Revision::Nec, + ExtBoard::M215 { .. } => Mmc3Revision::Sharp, }; + let mut regs = [0u8; 4]; + if matches!(board, ExtBoard::M215 { .. }) { + regs[1] = M215_OUTER_POWER_UP; + } Ok(Self { board, core: Mmc3::register_core(mirroring, revision), @@ -227,7 +313,7 @@ impl Mmc3BoardExt { chr, chr_is_ram, wram: vec![0u8; wram_bytes].into_boxed_slice(), - regs: [0; 4], + regs, }) } @@ -236,6 +322,23 @@ impl Mmc3BoardExt { let raw = self.core.prg_bank_raw(addr) as usize; match self.board { ExtBoard::M114 { .. } => nrom_override(self.regs[0], addr).unwrap_or(raw), + ExtBoard::M215 { sub1 } => { + let (mode, outer) = (self.regs[0], usize::from(self.regs[1])); + let inner = nrom_override(mode, addr).unwrap_or(raw); + // PRG A13-A17 inner; in 128 KiB mode A17 is `$5001` bit 4. + let inner = if mode & 0x40 != 0 { + (inner & 0x0F) | (((outer >> 4) & 1) << 4) + } else { + inner & 0x1F + }; + // PRG A18-A19 (sub 0), A18-A20 (sub 1). + let hi = if sub1 { + (outer & 3) | (((outer >> 3) & 1) << 2) + } else { + outer & 3 + }; + inner | (hi << 5) + } } } @@ -245,6 +348,22 @@ impl Mmc3BoardExt { match self.board { // `$6001` bit 0 is CHR A18 over the MMC3's A10-A17. ExtBoard::M114 { .. } => (raw & 0xFF) | (usize::from(self.regs[1] & 1) << 8), + ExtBoard::M215 { sub1 } => { + let outer = usize::from(self.regs[1]); + // CHR A10-A17 inner; in 128 KiB mode A17 is `$5001` bit 5. + let inner = if self.regs[0] & 0x40 != 0 { + (raw & 0x7F) | (((outer >> 5) & 1) << 7) + } else { + raw & 0xFF + }; + // CHR A18-A19 (sub 0: bits 2-3), A18-A20 (sub 1: bits 1-3). + let hi = if sub1 { + (outer >> 1) & 7 + } else { + (outer >> 2) & 3 + }; + inner | (hi << 8) + } } } @@ -258,6 +377,9 @@ impl Mmc3BoardExt { let (addr, value) = match self.board { ExtBoard::M114 { sub1: false } => unscramble(&M114_SUB0, addr, value), ExtBoard::M114 { sub1: true } => unscramble(&M114_SUB1, addr, value), + ExtBoard::M215 { .. } => { + unscramble(&M215_SCRAMBLE[usize::from(self.regs[2] & 7)], addr, value) + } }; self.core.cpu_write(addr, value); } @@ -276,6 +398,14 @@ impl Mapper for Mmc3BoardExt { MapperCaps::CYCLE_IRQ } + fn reset(&mut self) { + // 215: "The Outer Bank Register is reset to its power-up value if a + // Reset has been detected via an interruption of the M2 signal." + if matches!(self.board, ExtBoard::M215 { .. }) { + self.regs[1] = M215_OUTER_POWER_UP; + } + } + fn cpu_read_unmapped(&self, addr: u16) -> bool { match addr { 0x4020..=0x5FFF => true, @@ -312,6 +442,16 @@ impl Mapper for Mmc3BoardExt { self.wram[usize::from(addr - 0x6000) % len] = value; } } + (ExtBoard::M215 { .. }, 0x5000..=0x5FFF) => match addr & 0xF007 { + 0x5000 => self.regs[0] = value, + 0x5001 => self.regs[1] = value, + 0x5007 => self.regs[2] = value & 7, + _ => {} + }, + (_, 0x6000..=0x7FFF) if !self.wram.is_empty() && self.core.prg_ram_writable() => { + let len = self.wram.len(); + self.wram[usize::from(addr - 0x6000) % len] = value; + } _ => {} } } diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs index f335fb9e..2083253e 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs @@ -285,3 +285,182 @@ fn m114_state_round_trip_and_variant_refusal() { bad[8..12].copy_from_slice(&u32::MAX.to_le_bytes()); assert!(board(M114_0, 32, 256).load_state(&bad).is_err()); } + +// --------------------------------------------------------------------------- +// Mapper 215 (`INES_Mapper_215.md`). +// --------------------------------------------------------------------------- + +#[test] +fn m215_parses() { + for sub in [0u8, 1] { + assert!( + crate::parse(&nes2_image(215, sub, 32, 64)).is_ok(), + "mapper 215.{sub} must construct" + ); + } +} + +const M215_0: ExtBoard = ExtBoard::M215 { sub1: false }; +const M215_1: ExtBoard = ExtBoard::M215 { sub1: true }; + +/// Each `$5007` pattern, pinned to literal cells of the page's two tables: +/// where the bank select and bank data must be written, and which register +/// a written index then reaches. +#[test] +fn m215_scramble_patterns_follow_the_page() { + // (pattern, select addr, data addr, written index, observe at, is PRG) + for (pat, sel, data, written, window, is_prg) in [ + // 0, 5, 6, 7: the identity. Index 6 is R6. + (0u8, 0x8000u16, 0x8001u16, 6u8, 0x8000u16, true), + (5, 0x8000, 0x8001, 6, 0x8000, true), + (6, 0x8000, 0x8001, 6, 0x8000, true), + (7, 0x8000, 0x8001, 6, 0x8000, true), + // 1: `$A000` is `$8000`, `$C000` is `$8001`; written 4 means 7. + (1, 0xA000, 0xC000, 4, 0xA000, true), + // 1: written 2 means 6. + (1, 0xA000, 0xC000, 2, 0x8000, true), + // 2: addresses unscrambled; written 1 means 5 (R5 at `$1C00`). + (2, 0x8000, 0x8001, 1, 0x1C00, false), + // 3: `$8001` is `$8000`, `$A000` is `$8001`; written 1 means 6. + (3, 0x8001, 0xA000, 1, 0x8000, true), + // 3: written 4 means 5. + (3, 0x8001, 0xA000, 4, 0x1C00, false), + // 4: `$A000` is `$8000`, `$8001` stays `$8001`; written 4 means 6. + (4, 0xA000, 0x8001, 4, 0x8000, true), + // 4: written 1 means 2 (R2 at `$1000`). + (4, 0xA000, 0x8001, 1, 0x1000, false), + ] { + let mut m = board(M215_0, 32, 256); + m.cpu_write(0x5007, pat); + m.cpu_write(sel, written); + m.cpu_write(data, 0x0D); + let got = if is_prg { + prg_at(&mut m, window) + } else { + chr_at(&mut m, window) + }; + assert_eq!(got, 0x0D, "pattern {pat}, written index {written}"); + } +} + +/// Pattern 3 moves the IRQ registers: `$8000` is `$C001` (reload), `$E001` +/// is `$C000` (latch), `$C001` is `$E001` (enable). Pattern 1 moves the +/// mirroring register to `$8001` (`$A000`). +#[test] +fn m215_scramble_moves_irq_and_mirroring_registers() { + let mut m = board(M215_0, 32, 256); + m.cpu_write(0x5007, 3); + m.cpu_write(0xE001, 1); // latch = 1 + m.cpu_write(0x8000, 0); // reload + m.cpu_write(0xC001, 0); // enable + clock_a12(&mut m); + clock_a12(&mut m); + assert!( + m.irq_pending(), + "pattern 3: latch 1 fires on the second clock" + ); + + let mut m = board(M215_0, 32, 256); + m.cpu_write(0x5007, 1); + m.cpu_write(0x8001, 1); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); +} + +/// The page gives no IRQ revision; the MMC3 default (Sharp) applies, so a +/// latch of 0 fires on every clock. +#[test] +fn m215_irq_is_the_sharp_mmc3() { + let mut m = board(M215_0, 32, 256); + m.cpu_write(0xC000, 0); + m.cpu_write(0xC001, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + m.cpu_write(0xE000, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + assert!(m.irq_pending(), "Sharp: a latch of 0 fires every clock"); +} + +/// `$5001` powers up as `$xF` (the last outer bank: the reset vector is +/// reachable), a console reset restores it, and `$5000` / `$5007` keep their +/// values across the reset. +#[test] +fn m215_outer_bank_power_up_and_reset() { + let mut m = board(M215_0, 128, 1024); + assert_eq!(prg_at(&mut m, 0xE000), 127, "power-up: last 256 KiB"); + m.cpu_write(0x5001, 0x00); + m.cpu_write(0x5000, 0x80); + m.cpu_write(0x5007, 0x04); + assert_eq!(prg_at(&mut m, 0xE000), 1, "outer 0, NROM bank 0"); + m.reset(); + assert_eq!(m.regs[1], 0x0F, "$5001 back to its power-up value"); + assert_eq!(m.regs[0], 0x80, "$5000 kept"); + assert_eq!(m.regs[2], 0x04, "$5007 kept"); +} + +/// `$5000` is 114's NROM override (mask `$F007`, so `$5FF8` reaches it), and +/// bit 6 makes PRG A17 / CHR A17 come from `$5001` bits 4 / 5. +#[test] +fn m215_nrom_override_and_128k_mode() { + let mut m = board(M215_0, 128, 1024); + m.cpu_write(0x5001, 0x00); + m.cpu_write(0x5FF8, 0xA0 | 0x05); // NROM-256, bank 5 -> 4/5 + assert_eq!(prg_at(&mut m, 0x8000), 8); + assert_eq!(prg_at(&mut m, 0xC000), 10); + m.cpu_write(0x5000, 0x00); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x13); // MMC3 PRG A17 set + m.cpu_write(0x8000, 2); + m.cpu_write(0x8001, 0x85); // MMC3 CHR A17 set + assert_eq!(prg_at(&mut m, 0x8000), 0x13, "256 KiB mode: MMC3 A17"); + assert_eq!(chr_at(&mut m, 0x1000), 0x85); + m.cpu_write(0x5000, 0x40); // 128 KiB mode, $5001 bits 4/5 clear + assert_eq!(prg_at(&mut m, 0x8000), 0x03, "A17 from $5001 bit 4 (0)"); + assert_eq!(chr_at(&mut m, 0x1000), 0x05, "A17 from $5001 bit 5 (0)"); + m.cpu_write(0x5001, 0x30); + assert_eq!(prg_at(&mut m, 0x8000), 0x13, "A17 from $5001 bit 4 (1)"); + assert_eq!(chr_at(&mut m, 0x1000), 0x85, "A17 from $5001 bit 5 (1)"); +} + +/// The outer bank layouts: submapper 0 `..cp CCPP`, submapper 1 `..cp P.PP` +/// over `CCC.` (PRG A20 = bit 3, CHR A18-A20 = bits 1-3). +#[test] +fn m215_outer_bank_layouts() { + // 2 MiB PRG (256 x 8 KiB), 2 MiB CHR (2048 x 1 KiB). + let mut m = board(M215_0, 256, 2048); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x01); + m.cpu_write(0x8000, 2); + m.cpu_write(0x8001, 0x01); + m.cpu_write(0x5001, 0x0E); // PRG A19 (bit 1), CHR A18-A19 = 3 + assert_eq!(prg_at(&mut m, 0x8000), 0x01 | (2 << 5)); + assert_eq!(chr_at(&mut m, 0x1000), 0x01 | (3 << 8)); + m.cpu_write(0x5001, 0x08); + assert_eq!(prg_at(&mut m, 0x8000), 0x01, "sub 0: bit 3 is CHR only"); + + let mut m = board(M215_1, 256, 2048); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x01); + m.cpu_write(0x8000, 2); + m.cpu_write(0x8001, 0x01); + m.cpu_write(0x5001, 0x08); // PRG A20, CHR A20 + assert_eq!(prg_at(&mut m, 0x8000), 0x01 | (4 << 5)); + assert_eq!(chr_at(&mut m, 0x1000), 0x01 | (4 << 8)); + m.cpu_write(0x5001, 0x03); // PRG A18-A19, CHR A18 + assert_eq!(prg_at(&mut m, 0x8000), 0x01 | (3 << 5)); + assert_eq!(chr_at(&mut m, 0x1000), 0x01 | (1 << 8)); +} + +#[test] +fn m215_state_round_trip_and_variant_refusal() { + let mut m = board(M215_0, 64, 512); + m.cpu_write(0x5000, 0x45); + m.cpu_write(0x5001, 0x21); + m.cpu_write(0x5007, 0x03); + let blob = m.save_state(); + let mut n = board(M215_0, 64, 512); + n.load_state(&blob).expect("same variant"); + assert_eq!(n.save_state(), blob); + assert!(board(M215_1, 64, 512).load_state(&blob).is_err()); + assert!(board(M114_0, 64, 512).load_state(&blob).is_err()); +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index fcd3eafb..421538f0 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -132,16 +132,19 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // GTROM (111), promoted from BestEffort once the board matched its // page (register window, bonus RAM, self-flashing) and a CC0 fixture // pinned it. None has a redistributable commercial ROM. - // - // v3.2.0 adds MMC3-derived boards written from their NESdev pages + 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => { + Some(MapperTier::Curated) + } + + // --- v3.2.0: MMC3-derived boards written from their NESdev pages // (`mmc3_boards_ext.rs`), each Curated on a D23 record in // `docs/compatibility.md`: a local dump that boots to its title, named // by title and `Nes::rom_sha256`, pinned by a boot snapshot in // `external_extended.rs` and a committed screenshot. 182 is a - // duplicate of 114 (the 114 page) and shares its model. - 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 | 114 | 182 => { - Some(MapperTier::Curated) - } + // duplicate of 114 (the 114 page) and shares its model. A separate arm + // so each release's batch keeps its own rationale beside its ids. + #[allow(clippy::match_same_arms)] + 114 | 182 | 215 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -251,7 +254,7 @@ mod tests { // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, // v3.2.0 (`mmc3_boards_ext.rs`). - 114, 182, + 114, 182, 215, ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 7eabc395..a7a000e7 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1457,3 +1457,23 @@ fn extended_m182_super_donkey_kong() { "extended_m182_super_donkey_kong", ); } + +/// Scramble pattern 4 (`$5007` = 4), 256 KiB PRG + 512 KiB CHR. +#[test] +fn extended_m215_mortal_kombat_3_extra_60() { + check( + "mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m215_mortal_kombat_3_extra_60", + ); +} + +/// Scramble pattern 1 (`$5007` = 1): the 2-in-1 menu. +#[test] +fn extended_m215_super_808() { + check( + "mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].zip", + DEFAULT_IDLE, + "extended_m215_super_808", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_mortal_kombat_3_extra_60.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_mortal_kombat_3_extra_60.snap new file mode 100644 index 00000000..884a6f78 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_mortal_kombat_3_extra_60.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].zip +rom_sha256=4796d6a0b3dd0e41b2117205d692ffd35b5781479c9c81d19f3a466d8a594282 +frames=600 +fb_bytes=245760 +cycles=17838522 +audio_samples=439524 +audio_fnv1a64=1bbb5de785246413 +checkpoint f600 fb_fnv1a64=3752b8514be3b244 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_super_808.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_super_808.snap new file mode 100644 index 00000000..49bd6187 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m215_super_808.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].zip +rom_sha256=f03f7933b78e084e16bfe11e3f58bb492412f044ff7cff5226b247cb9ba8da46 +frames=600 +fb_bytes=245760 +cycles=17838520 +audio_samples=439524 +audio_fnv1a64=dda9e74ae2696052 +checkpoint f600 fb_fnv1a64=78bea01365527c18 diff --git a/docs/compatibility.md b/docs/compatibility.md index 22e35458..30dce72c 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -376,3 +376,5 @@ screenshot of that frame. The dumps themselves stay in the gitignored | 114 | Aladdin (SuperGame) (Mapper 114) [!] | `5489244538c94653f766bd80f07b2aa684ad139bddea68f40b60a0473c9286e2` | 600 | `screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png` | | 114.1 | Boogerman (Sugar Softec) (Unl) [!] (GoodNES labels it 215; staged with a NES 2.0 114.1 header) | `ba2d65f240d8f97c7b77973042d8141cf15116ffc3d73caed450c21e08fd54a2` | 600 | `screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png` | | 182 (as 114.0) | Super Donkey Kong (Unl) [!] | `67d06173c4b8239d46c84335591216b1adfa38344107405ec88c58327217a361` | 600 | `screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png` | +| 215 | Mortal Kombat 3 Extra 60 (Unl) [!] (scramble pattern 4) | `19ccf8d3a3fc37b82d403d0668b00f05125bfcf01ad510dfff73e3ac30dad827` | 600 | `screenshots/external/mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].png` | +| 215 | `2-in-1 Super 808 (Unl) [p1][!]` (scramble pattern 1, menu) | `4211468e61181f0956f10259ba6091ea3fead297e61ba7f22d7daa227463414a` | 600 | `screenshots/external/mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index 5c89414b..f77e392d 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,7 @@ boot snapshot in `external_extended.rs` and a committed screenshot. |------|-----|--------------|------|-----|-------| | 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. GoodNES labels *Boogerman* (Sugar Softec) mapper 215, the assignment the 215 page says was withdrawn; it never writes `$5007` and boots only as 114.1, so its D23 record uses a copy with a NES 2.0 114.1 header (`Nes::rom_sha256` excludes the header, so the identity is the dump's). | | 182 | — | (`INES_Mapper_182` → `INES_Mapper_114`) | Curated | as 114 | A duplicate of 114: the 182 page is a redirect, and the 114 page says Hosenkan's 182 games write 0 to `$6000`/`$6001` once and never again. Dispatched as 114 submapper 0; the full 114 model is kept rather than the "no `$600x`" simplification, since the zero writes leave it in the MMC3's own banking. | +| 215 | 0/1 | Sugar Softec UNL-8237 / UNL-8237A (`INES_Mapper_215`) | Curated | MMC3 (Sharp; the page names no revision) | `$5007` (mask `$F007`) selects one of eight scramble patterns (0, 2, 5, 6, 7 are the identity; 1, 3, 4 per the page's tables). `$5000` `MCS. BBBb` is 114's NROM override plus `C`, the 128 KiB mode that replaces PRG A17 / CHR A17 with `$5001` bits 4 / 5; `C` is applied after the NROM override, since the page describes it as a substitution on the A17 line both drive (the order is not stated). `$5001` is the outer bank (sub 0: PRG A18-A19 = bits 0-1, CHR A18-A19 = bits 2-3; sub 1: PRG A18/A19/A20 = bits 0/1/3, CHR A18-A20 = bits 1-3), power-up `$xF` (taken as `$0F`) and restored on a console reset; `$5000` and `$5007` power up `$00`. No WRAM unless a NES 2.0 header declares it. The page's index tables were checked black-box: pattern 1 (the *Super 808* menu, then *Aladdin* selected from it) and pattern 4 (*Mortal Kombat 3 Extra 60*) both run, and pattern 1's index table is the inverse of 114 submapper 0's, as printed. The "alternative outer bank registers in the `$6xxx` range" some multicarts carry are not modelled. | ### Mapper accuracy tiering (v1.2.0) diff --git a/screenshots/external/mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].png b/screenshots/external/mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].png new file mode 100644 index 0000000000000000000000000000000000000000..6e21a1297e582ec390c7e8bf6ee594fd1a400ae9 GIT binary patch literal 1759 zcmdT_{ZrC+0RHe2TA^r~mrJQ&~%P7l=hZZ8jD%2EKNi0Mfj?zS8aN& zI&4w8y7R3v&88^`mbd6~rVSJrZ=yr;1t<{^RG9q_`=R~v+;jKb^UHJhJkLdXQi9jA z)yn_?Ueqt+z5)QTNWoiA%p#h(oxK2HYN>IQZwh!ubxV$40ubwZMyXdfKG@VK*mZl| zR&w7@iAUe4CFJqIW9h-kSN7K2xxzA=^Ro(G3^vc3Io?E zxTlEv3m~bQM)u4*d>5Ca?gPg>o$tLTAK*HIR2YBs3u1>yFxqDus@^yJO-Qz1v-kW8 zdu^>!1QJbVN(G2H$!EU`V~_@Hwfdl;VUfzNIbTm{JC2$+EcPtl-wDM0&y z4_c5nBC{=er<@ByGa?WL?g`soU$`3x&R9-NGUJio1;DhDHo+WczJPdc3gU1`vpU~& z>|*ZQA8qUIjhVM=U8~t$j@(&&<>lAR#+*#fZhUEj0hnS40kv^8-eX#}NHFK*_d^~I zyUs=Y#5-d^X7OUD>;W%_gUT-RQ^}`dYPJ#td`_n`uvE5J=8nz7Us76%u5Up&H?RZc z8FV{Jbjv9lRUVxYgG~yw;MNjszS+fsxs){5fuJmpyjWqT_fRT@gKho=ND(_C2kJ1a zSYghA4TUpulTxC6Ru5XQYGFu*(JsB>QZeth;mbMqq5>K(k_vPJB&4 zR@BEltp!I#4;>*U!md!qNukP3skvjaX*FgMPerQ7mzZ}R+@XxJOa15R z{cfqjeDBlpio&(`!-4XAL0uP3j+@F;43z8WV?%>;P5JGq?U3G49^M}MY20N1S(AMYi^+AGC9s$|nw1tnM1`U9l@Tu0+7hOhtFCJ0nrVkPav{jFz3xXM zv`jGWR~*kL4B)0a6$ZbeY$PHK<#3g0d-ijTqb$s8%w73uHzDQ4ikfe^NLHHr-GnZQ z-L;%vv4P;#^io41AQV(jxwlA=w{7$%j-?8mWIF*FcDI6Ca9tBi5W_^rdn5*Q)lP zZHYz5|5816Y~TW=(6w`Nq>#JB7|rL2%+^+XBJ7tlrKgAt&ApCXe91_5%3G>d+lJUa2)%4N2i;@02K7apT N)cB;hrd^EUe*v`NzCr*1 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].png b/screenshots/external/mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..f836d03c008393c4de8878b645a88cf4d901a5e7 GIT binary patch literal 5898 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 Date: Thu, 8 Oct 2026 20:20:30 -0400 Subject: [PATCH 007/104] feat(mappers): add mapper 158 (Tengen 800037, RAMBO-1 with CHR A17 on CIRAM A10) Mapper 158 is the board of Tengen's Alien Syndrome. Written from the vendored NESdev pages INES_Mapper_158 and RAMBO_1 (Variants); no reference-emulator source was read. Per both pages it modifies the RAMBO-1 "in the exact same way that TLSROM (118) differs from MMC3": CHR A17 drives CIRAM A10, so "bit 7 of each CHR bank mapped into PPU $0000-$0FFF controls which page of CIRAM is used for the corresponding nametable in $2000-$2FFF". Implementation: a construction-time variant of the project's own Rambo1 (m064_rambo1.rs, no provenance header), Rambo1::new_m158. Nametable n takes bit 7 of the 1 KiB bank resolved for slot n of the $0000-$0FFF half, after the C (A12 inversion) and K (full 1 KiB) bits: R0/R1 in 2 KiB mode, R0/R8/ R1/R9 with K, R2-R5 under C. The $A000 mirroring register "applies to mapper 64 only" and is ignored. current_mirroring reports MapperControlled and nametable_address resolves the page. Banking, both IRQ modes and the save-state layout are mapper 64's; mapper 64's own path is unchanged (its existing tests pass, and a new one pins $A000 still setting H/V on 64). Verification - Red first: the four new parse-driven tests failed with UnsupportedMapper(158) before the dispatch arm. 14 m064 tests pass. - Alien Syndrome (USA) (Unl) (NES 2.0, mapper 158) boots through its story intro; START reaches the character-select screen; with no input its attract demo is in play by frame 2400 with the status bar over a scrolling playfield. The boot snapshot runs to f2400 for that reason: the 600-frame intro text uses one CIRAM page and did not see the mutation below. external_coverage snapshot blessed; D23 row and screenshot committed. - Mutation: taking CIRAM A10 from bit 6 instead of bit 7 failed "2 KiB R0/R1 drive two tables each" (left [0,0,0,0], right [1,1,0,0]) and the C-inversion test, and moved the f2400 boot frame (cb176498c8074e62 -> 81747a701de8eab3). Tier: Curated. Not verified: the RAMBO-1 IRQ +1 reload kick and the one-cycle assertion delay remain unmodelled, as for mapper 64. The GoodNES "(Tengen) [!]" image has the same body headered 118; the game database lists that CRC as 118, and its NES 2.0 guard leaves the 158 header of the staged dump alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 6 + crates/rustynes-mappers/src/m064_rambo1.rs | 149 +++++++++++++++++- crates/rustynes-mappers/src/tier.rs | 18 ++- .../tests/external_extended.rs | 14 ++ ...ngen800037_Alien_Syndrome_USA_Unl_zip.snap | 13 ++ ...xtended__extended_m158_alien_syndrome.snap | 12 ++ docs/compatibility.md | 1 + docs/mappers.md | 1 + .../Alien Syndrome (USA) (Unl).png | Bin 0 -> 5343 bytes 9 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_158_Tengen800037_Alien_Syndrome_USA_Unl_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m158_alien_syndrome.snap create mode 100644 screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 5de30af9..1ca62f71 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1584,6 +1584,12 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { Txc173::new(prg_rom, chr_rom, h.mirroring) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // 158: Tengen 800037 (*Alien Syndrome*), a RAMBO-1 whose CHR A17 + // drives CIRAM A10 per nametable (`m064_rambo1.rs`, `Rambo1::new_m158`). + 158 => Box::new( + Rambo1::new_m158(prg_rom, chr_rom) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/m064_rambo1.rs b/crates/rustynes-mappers/src/m064_rambo1.rs index 37af1552..c3d10af8 100644 --- a/crates/rustynes-mappers/src/m064_rambo1.rs +++ b/crates/rustynes-mappers/src/m064_rambo1.rs @@ -15,6 +15,21 @@ //! See `nesdev_wiki/RAMBO_1.xhtml`, `nesdev_wiki/INES_Mapper_064.xhtml`, and //! `docs/mappers.md`. //! +//! # Mapper 158 (Tengen 800037, *Alien Syndrome*) +//! +//! Built with [`Rambo1::new_m158`]. Per `nesdev_wiki/INES_Mapper_158.xhtml` it +//! "modifies RAMBO-1 (mapper 64) in the exact same way that TLSROM (118) +//! differs from MMC3 (4)": the chip's CHR A17 output drives CIRAM A10, so "bit +//! 7 of each CHR bank mapped into PPU `$0000-$0FFF` controls which page of +//! CIRAM is used for the corresponding nametable in `$2000-$2FFF`" +//! (`RAMBO_1.xhtml`, Variants). Nametable *n* takes bit 7 of the 1 KiB bank in +//! slot *n* of the `$0000-$0FFF` half, after the C (A12 inversion) and K +//! (full 1 KiB) bits; in 2 KiB mode both slots of a 2 KiB bank carry the same +//! register's bit 7. The `$A000` mirroring register "applies to mapper 64 +//! only", so the board ignores it. Banking, IRQ and save-state layout are +//! mapper 64's unchanged; the variant is a construction-time property, not +//! state. +//! //! # Banking registers //! //! `$8000` (even) bank-select: bits 0-3 pick the register, bit 5 = full-1KiB @@ -91,6 +106,9 @@ pub struct Rambo1 { chr_1k_mode: bool, // $8000 bit 5 (K) mirroring: Mirroring, + /// Mapper 158: CIRAM A10 is CHR A17 (bit 7 of the nametable's slot bank), + /// and `$A000` is not connected. + ciram_from_chr_a17: bool, // IRQ. irq_latch: u8, @@ -150,6 +168,7 @@ impl Rambo1 { chr_mode: false, chr_1k_mode: false, mirroring, + ciram_from_chr_a17: false, irq_latch: 0, irq_counter: 0, irq_reload_pending: false, @@ -163,6 +182,19 @@ impl Rambo1 { }) } + /// Construct the mapper 158 board (Tengen 800037): a RAMBO-1 whose CHR A17 + /// output selects the CIRAM page per nametable (see the module docs). The + /// header's mirroring is meaningless on it and is not taken. + /// + /// # Errors + /// + /// As [`Self::new`]. + pub fn new_m158(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result { + let mut m = Self::new(prg_rom, chr_rom, Mirroring::MapperControlled)?; + m.ciram_from_chr_a17 = true; + Ok(m) + } + /// RF lives at register index 15. const RF: usize = 15; @@ -243,7 +275,12 @@ impl Rambo1 { fn nametable_offset(&self, addr: u16) -> usize { let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8; let local = (addr as usize) & (NAMETABLE_SIZE - 1); - let physical = self.mirroring.physical_bank(table); + let physical = if self.ciram_from_chr_a17 { + // Mapper 158: nametable n reads CIRAM page = CHR A17 of slot n. + (self.chr_bank_1k(table as usize) >> 7) & 1 + } else { + self.mirroring.physical_bank(table) + }; physical * NAMETABLE_SIZE + local } @@ -302,7 +339,8 @@ impl Mapper for Rambo1 { } } 0xA000..=0xBFFF => { - if addr & 1 == 0 { + // Mapper 158 does not connect the mirroring register. + if addr & 1 == 0 && !self.ciram_from_chr_a17 { self.mirroring = if value & 1 == 0 { Mirroring::Vertical } else { @@ -371,7 +409,12 @@ impl Mapper for Rambo1 { } fn current_mirroring(&self) -> Mirroring { - self.mirroring + if self.ciram_from_chr_a17 { + // Per-nametable and dynamic; `nametable_address` resolves it. + Mirroring::MapperControlled + } else { + self.mirroring + } } fn notify_a12(&mut self, level: bool) { @@ -416,9 +459,13 @@ impl Mapper for Rambo1 { fn debug_info(&self) -> crate::mapper::MapperDebugInfo { let mut info = crate::mapper::MapperDebugInfo { - mapper_id: 64, - name: "Tengen RAMBO-1 (64)".into(), - mirroring: crate::mapper::mirroring_name(self.mirroring), + mapper_id: if self.ciram_from_chr_a17 { 158 } else { 64 }, + name: if self.ciram_from_chr_a17 { + "Tengen 800037 / RAMBO-1 (158)".into() + } else { + "Tengen RAMBO-1 (64)".into() + }, + mirroring: crate::mapper::mirroring_name(self.current_mirroring()), ..Default::default() }; info.prg_banks @@ -742,4 +789,94 @@ mod tests { assert_eq!(m.irq_cpu_mode, m2.irq_cpu_mode); assert_eq!(m.ppu_read(0x2000), m2.ppu_read(0x2000)); } + + // --- Mapper 158 (Tengen 800037) ------------------------------------ + + /// A mapper 158 image through `crate::parse`, so the dispatch arm is + /// under test: 128 KiB PRG, 128 KiB CHR, header says horizontal. + fn m158() -> Box { + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = 8; + v[5] = 16; + v[6] = 0xE0; // mapper low nibble 0xE, horizontal + v[7] = 0x90; // mapper high nibble 0x9 -> 0x9E = 158 + v.extend_from_slice(&synth_prg(16)); + v.extend_from_slice(&synth_chr(128)); + crate::parse(&v) + .unwrap_or_else(|e| panic!("mapper 158 must parse: {e:?}")) + .1 + } + + /// The CIRAM page (0 or 1) nametable `n` resolves to. + fn page(m: &dyn Mapper, n: u16) -> u16 { + m.nametable_address(0x2000 + n * 0x400 + 0x15) >> 10 + } + + fn sel(m: &mut dyn Mapper, reg: u8, value: u8) { + m.cpu_write(0x8000, reg); + m.cpu_write(0x8001, value); + } + + #[test] + fn m158_nametables_follow_bit7_of_the_0000_half_banks_in_2k_mode() { + let mut m = m158(); + assert_eq!(m.current_mirroring(), Mirroring::MapperControlled); + sel(m.as_mut(), 0, 0x80); // R0: CHR A17 = 1 for slots 0-1 + sel(m.as_mut(), 1, 0x02); // R1: CHR A17 = 0 for slots 2-3 + let pages: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect(); + assert_eq!(pages, [1, 1, 0, 0], "2 KiB R0/R1 drive two tables each"); + // The local offset survives. + assert_eq!(m.nametable_address(0x2015) & 0x3FF, 0x15); + // $A000 is not connected on this board. + m.cpu_write(0xA000, 0x01); + m.cpu_write(0xA000, 0x00); + let again: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect(); + assert_eq!(again, [1, 1, 0, 0], "$A000 ignored"); + } + + #[test] + fn m158_full_1k_mode_uses_r0_r8_r1_r9() { + let mut m = m158(); + // K=1 (bit 5): slots 0-3 = R0, R8, R1, R9. + m.cpu_write(0x8000, 0x20 | 8); + m.cpu_write(0x8001, 0x80); // R8 + m.cpu_write(0x8000, 0x20 | 9); + m.cpu_write(0x8001, 0x00); // R9 + m.cpu_write(0x8000, 0x20); + m.cpu_write(0x8001, 0x00); // R0 + m.cpu_write(0x8000, 0x20 | 1); + m.cpu_write(0x8001, 0x81); // R1 + let pages: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect(); + assert_eq!(pages, [0, 1, 1, 0]); + } + + #[test] + fn m158_a12_inversion_moves_the_source_to_r2_r5() { + let mut m = m158(); + // C=1 (bit 7): $0000-$0FFF is R2..R5. + for (r, v) in [(2u8, 0x80u8), (3, 0x00), (4, 0x80), (5, 0x00)] { + m.cpu_write(0x8000, 0x80 | r); + m.cpu_write(0x8001, v); + } + let pages: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect(); + assert_eq!(pages, [1, 0, 1, 0]); + } + + #[test] + fn m158_state_round_trips_and_mapper_64_keeps_its_register() { + let mut a = m158(); + sel(a.as_mut(), 0, 0x80); + a.ppu_write(0x2000, 0x5A); + let blob = a.save_state(); + let mut b = m158(); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.ppu_read(0x2000), 0x5A); + assert_eq!(page(b.as_ref(), 0), 1); + // Mapper 64 is untouched: $A000 still sets H/V. + let mut m = fresh(); + m.cpu_write(0xA000, 0x01); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + } } diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 39737a65..8a663d92 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -132,15 +132,18 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // GTROM (111), promoted from BestEffort once the board matched its // page (register window, bonus RAM, self-flashing) and a CC0 fixture // pinned it. None has a redistributable commercial ROM. - // - // v3.2.0 joins this arm: families written from a NESdev page with - // exact register masks, each also booted on a local dump recorded with - // its title, header-excluded hash, frames and a committed screenshot - // (D23, `docs/compatibility.md`). - 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 | 172 | 173 => { + 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => { Some(MapperTier::Curated) } + // --- v3.2.0: families written from a NESdev page with exact register + // masks, each also booted on a local dump recorded with its title, + // header-excluded hash, frames and a committed screenshot (D23, + // `docs/compatibility.md`). A separate arm so each release's batch + // reads as one list; the bodies match on purpose. + #[allow(clippy::match_same_arms)] + 158 | 172 | 173 => Some(MapperTier::Curated), + // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot // be honestly oracle-gated and stay register-decode + save-state @@ -247,7 +250,8 @@ mod tests { 178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". - 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, // v3.2.0 + 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, + 158, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index f8bbb5dd..32293e82 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1441,3 +1441,17 @@ fn extended_m173_xiao_ma_li() { "extended_m173_xiao_ma_li", ); } + +/// 2400-frame idle: *Alien Syndrome*'s attract demo is in gameplay by then, +/// scrolling under its status bar, which is where mapper 158's per-nametable +/// CIRAM select shows. The 600-frame intro text uses one page only. +const ALIEN_SYNDROME_DEMO: InputScript = InputScript::IdleOnly { frames: 2400 }; + +#[test] +fn extended_m158_alien_syndrome() { + check( + "mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).zip", + ALIEN_SYNDROME_DEMO, + "extended_m158_alien_syndrome", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_158_Tengen800037_Alien_Syndrome_USA_Unl_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_158_Tengen800037_Alien_Syndrome_USA_Unl_zip.snap new file mode 100644 index 00000000..945dce0b --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_158_Tengen800037_Alien_Syndrome_USA_Unl_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).zip +rom_sha256=177c305b849cb5ee725cfacc0020c5e8b42f6ab88b75b53645a7be37c7b76d83 +frames=1141 +fb_bytes=245760 +cycles=33949773 +audio_samples=836505 +audio_fnv1a64=b44c9ff7cb7878be +checkpoint f900 fb_fnv1a64=c7f5b55837976ffe +checkpoint f1100 fb_fnv1a64=c7f5b55837976ffe diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m158_alien_syndrome.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m158_alien_syndrome.snap new file mode 100644 index 00000000..f27cc3c1 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m158_alien_syndrome.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).zip +rom_sha256=177c305b849cb5ee725cfacc0020c5e8b42f6ab88b75b53645a7be37c7b76d83 +frames=2400 +fb_bytes=245760 +cycles=71443424 +audio_samples=1760348 +audio_fnv1a64=43d5d07f0d444300 +checkpoint f2400 fb_fnv1a64=cb176498c8074e62 diff --git a/docs/compatibility.md b/docs/compatibility.md index 3a193773..d30e55b0 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -375,3 +375,4 @@ in `crates/rustynes-test-harness/tests/external_extended.rs`. |--------|-------|-----------------------------------------------------------|--------|------------| | 172 | 1991 Du Ma Racing (Ch) [!] | `4a2ec0f51526ad9659b0ede758216ea8f7acae7d49faaed5db46b6acc7be6915` | 600 | `screenshots/external/mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].png` | | 173 | Xiao Ma Li (Ch) [!] | `103e27a9b2e2c029fee9a221e2ffe35b5b0b4a3e529a4df88fd6d71f21cf56a4` | 600 | `screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png` | +| 158 | Alien Syndrome (USA) (Unl) | `d676ef6f7aa3b042ae5a0c95a7f07fabb4c3a5fccb3767ae1950713e51189a47` | 2400 (attract demo, in play) | `screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png` | diff --git a/docs/mappers.md b/docs/mappers.md index e463a121..247160a5 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -851,6 +851,7 @@ committed screenshot) and pinned by a byte-identity boot snapshot in |------|-----|--------------|------|-----|-------| | 172 | — | Super Mega P-4070, JV001 (`INES_Mapper_172`, `INES_Mapper_136`) | Curated | — | `m172_m173_txc.rs`. The JV001's Input / Register / Output with CPU D0-D5 wired in reverse; Mode and Invert from CPU D5. `$8000-$FFFF` latches Output (CHR bank = Output & 3 in chip order) and sets mirroring from Invert (H when clear, V when set); the header's mirroring holds until then. PRG fixed 32 KiB. `$4100` reads drive D0-D5 only. *1991 Du Ma Racing* passes its protection check and plays. | | 173 | — | Idea-Tek ET-xx, 05-00002-010 (`INES_Mapper_173`) | Curated | — | Same module. P/R/Output 3 bits, S/C/V 1 bit; `$4100` reads `[xxxx SRRR]` with S XOR V, D4-D7 open bus. CHR bank = (Output & 1) \| (~V << 1), V read live. Hard-wired mirroring. *Xiao Ma Li*'s 8 KiB EPROM with `/PGM` on pin 27 is not modelled beyond wrapping (the page does not say what a disabled CHR read returns). The TXC *Mahjong Block* (CRC32 `0ACFC3CD`), commonly headered 173, is a mapper 132 board per both pages: as 173 it draws garbage, as the page predicts. | +| 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | ### Mapper accuracy tiering (v1.2.0) diff --git a/screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png b/screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..f1bfdf39498b4effa8fec6394165da0c874a9a80 GIT binary patch literal 5343 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 N-gD5g!rt$~{{T;DAY%Xk literal 0 HcmV?d00001 From dcc1b0555a6d8684fd5ce5e9f5f4fb819b43d8a5 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 20:33:26 -0400 Subject: [PATCH 008/104] fix(mappers): mapper 290's 32 KiB PRG mode maps both halves in order Found while gathering tier evidence for v3.2.0 (plan items 3/4): the one library dump for mapper 290, Asder 20-in-1 (BMC-NTD-03), booted to a uniform grey frame. The cause, from nesdev_wiki/NES_2_0_Mapper_290.xhtml: the board latches its whole configuration from the address of a write to $8000-$FFFF, `A~[1PPP PMCC Sp.. .CCC]`. `S` (A7) picks the PRG window: 0 maps the 32 KiB bank `PPPP` across $8000-$FFFF, its two 16 KiB halves in order; 1 maps the 16 KiB bank `PPPPp` at both halves. The write path decoded that correctly into `reg0` (the 16 KiB bank number), but the read path called `prg_16k` in BOTH modes, and `prg_16k` masks the address with 0x3FFF. In 32 KiB mode $C000-$FFFF therefore repeated $8000's half. The latch powers up at 0, which is 32 KiB mode, so the reset vector at $FFFC came from the lower half and the cart never started. The fix records the latch in the board's existing `last_addr` field (already in its save state, used until now only by board 301) and reads the window size from its A7: 16 KiB mode keeps `prg_16k(reg0)`, 32 KiB mode reads `prg_32k(reg0 >> 1)`. No save-state format change. An older state of this board carries `last_addr` 0, i.e. 32 KiB mode; the game could not boot before, so no meaningful older state exists. Red first: `m290_prg_modes_follow_the_size_bit` asserts power-on 32 KiB mode ($C000 = half 1), 32 KiB bank 1 (halves 2 and 3), 16 KiB banks 3 and 2 at both halves, and the mode across a save-state round trip. Before the fix it failed on its first assertion ("power-on is 32 KiB mode: $C000 is the upper half", left 0, right 1); after, 2/2 mapper-290 tests pass. The cart now starts a game (screenshots/besteffort/mapper-290-BMC-NTD03/, the coverage harness's final frame, and its blessed snapshot). It stays BestEffort: that frame shows a top band of scrambled tiles that nobody has compared with hardware (NESdev notes one game on the cart glitches in attract mode on a real console), so it is not D23 evidence. This changes emulated output against v3.1.0, so EMULATION_EPOCH rises to 4 (ADR 0045), with its table row. The epoch fingerprint table is re-blessed: its panel does not include 290, so only its `# epoch` line moved (3 -> 4); `last_release_epoch` stays 3. epoch_fingerprint 2/2, rustynes-core lib 253/253, rustynes-netplay green at epoch 4. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/hardware_options.rs | 3 +- .../src/multicart_discrete.rs | 53 +++++++++++++++++- .../golden/epoch_fingerprint.tsv | 2 +- ...0_BMC_NTD03_Asder_20_in_1_USA_Unl_zip.snap | 13 +++++ .../Asder - 20 in 1 (USA) (Unl).png | Bin 0 -> 5009 bytes 5 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_290_BMC_NTD03_Asder_20_in_1_USA_Unl_zip.snap create mode 100644 screenshots/besteffort/mapper-290-BMC-NTD03/Asder - 20 in 1 (USA) (Unl).png diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index 95467e71..e78add46 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -96,7 +96,8 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize; /// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) | /// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) | /// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) | -pub const EMULATION_EPOCH: u32 = 3; +/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots) | +pub const EMULATION_EPOCH: u32 = 4; /// Every host-settable option that changes what the emulated console does. /// diff --git a/crates/rustynes-mappers/src/multicart_discrete.rs b/crates/rustynes-mappers/src/multicart_discrete.rs index 742390fa..7b571d28 100644 --- a/crates/rustynes-mappers/src/multicart_discrete.rs +++ b/crates/rustynes-mappers/src/multicart_discrete.rs @@ -3506,7 +3506,18 @@ impl Mapper for DiscreteMapper { }, DiscreteBoard::M290 => { if (0x8000..=0xFFFF).contains(&addr) { - self.prg_16k(self.reg0 as usize, addr) + // The latch's `S` bit (A7) picks the window size. `reg0` + // is the 16 KiB bank number (`PPPP` doubled, plus `p` in + // 16 KiB mode), so the 32 KiB bank is `reg0 >> 1`. v3.2.0: + // until then both modes read through the 16 KiB window, + // which repeats `$8000`'s half at `$C000` and broke 32 KiB + // mode -- the power-on mode, so the reset vector came from + // the wrong half (`m290_prg_modes_follow_the_size_bit`). + if self.last_addr & 0x80 != 0 { + self.prg_16k(self.reg0 as usize, addr) + } else { + self.prg_32k(self.reg0 as usize >> 1, addr) + } } else { 0 } @@ -3588,6 +3599,9 @@ impl Mapper for DiscreteMapper { } else { Mirroring::Vertical }; + // The whole latch, for its `S` bit (read path); already + // part of the board's save state. + self.last_addr = addr; } } DiscreteBoard::M301 => { @@ -4732,6 +4746,43 @@ mod tests { assert_eq!(m.cpu_read(0xC000), 47 % 32); // high|0x0F = 47. } + /// v3.2.0: `nesdev_wiki/NES_2_0_Mapper_290.xhtml`, `A~[1PPP PMCC Sp.. .CCC]`: + /// `S` = 0 maps the 32 KiB bank `PPPP` across all of `$8000-$FFFF` (its two + /// 16 KiB halves IN ORDER), `S` = 1 maps 16 KiB bank `PPPPp` at both halves. + /// Until v3.2.0 the read path used the 16 KiB window in both modes, so in + /// 32 KiB mode `$C000-$FFFF` repeated `$8000`'s half; the latch powers up + /// at 0, which is 32 KiB mode, so the reset vector came from the wrong + /// half and *Asder 20-in-1* never started (a uniform grey frame). + #[test] + fn m290_prg_modes_follow_the_size_bit() { + let mut m = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap(); + // Power-on: 32 KiB bank 0, halves 0 and 1. + assert_eq!(m.cpu_read(0x8000), 0); + assert_eq!( + m.cpu_read(0xC000), + 1, + "power-on is 32 KiB mode: $C000 is the upper half" + ); + // S=0, PPPP=1: 32 KiB bank 1 = 16 KiB banks 2 and 3. + m.cpu_write(0x8800, 0); + assert_eq!(m.cpu_read(0x8000), 2); + assert_eq!(m.cpu_read(0xC000), 3); + // S=1, p=1, PPPP=1: 16 KiB bank 3 at both halves. + m.cpu_write(0x88C0, 0); + assert_eq!(m.cpu_read(0x8000), 3); + assert_eq!(m.cpu_read(0xC000), 3); + // S=1, p=0: 16 KiB bank 2 at both halves. + m.cpu_write(0x8880, 0); + assert_eq!(m.cpu_read(0x8000), 2); + assert_eq!(m.cpu_read(0xC000), 2); + // The mode survives a save-state round trip. + m.cpu_write(0x8800, 0); + let blob = m.save_state(); + let mut m2 = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap(); + m2.load_state(&blob).unwrap(); + assert_eq!(m2.cpu_read(0xC000), 3); + } + #[test] fn m290_address_decoded_mirror() { let mut m = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap(); diff --git a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv index b3146036..f50c37d7 100644 --- a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv +++ b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv @@ -3,7 +3,7 @@ # RUSTYNES_BLESS_EPOCH_FINGERPRINT=1. `last_release_epoch` is the epoch # the last release shipped, set by hand at each release cut: output may # move only while EMULATION_EPOCH is above it. -# epoch 3 +# epoch 4 # last_release_epoch 3 # rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_290_BMC_NTD03_Asder_20_in_1_USA_Unl_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_290_BMC_NTD03_Asder_20_in_1_USA_Unl_zip.snap new file mode 100644 index 00000000..f1d7f1c6 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_290_BMC_NTD03_Asder_20_in_1_USA_Unl_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-290-BMC-NTD03/Asder - 20 in 1 (USA) (Unl).zip +rom_sha256=c167813281520da38dc8f46040a82d7e3956e40dd417c16f7a48c6fb43bdce31 +frames=1141 +fb_bytes=245760 +cycles=33949787 +audio_samples=836505 +audio_fnv1a64=3531b2e5f8d130fe +checkpoint f900 fb_fnv1a64=5da1e1571a9fffbb +checkpoint f1100 fb_fnv1a64=5da1e1571a9fffbb diff --git a/screenshots/besteffort/mapper-290-BMC-NTD03/Asder - 20 in 1 (USA) (Unl).png b/screenshots/besteffort/mapper-290-BMC-NTD03/Asder - 20 in 1 (USA) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..428b40207afbe16ac1d816482b21942bdd4812cc GIT binary patch literal 5009 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 literal 0 HcmV?d00001 From 3621db661a093ebb580e00e65601fa8e90145905 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 20:34:30 -0400 Subject: [PATCH 009/104] test(harness): arbitrary single-frame presses; reach mapper 47's second game The commercial-ROM harness could only press START (`StartTap`, `DoubleStartTap`, `RepeatStartTap`), so a menu that needs another button was out of reach. Mapper 47 is the case that motivated it: *Super Spike V'Ball + Nintendo World Cup* opens on a game-select screen, and every existing capture took the default, the first game, which lives in 128 KiB block 0. The block-select register NESdev describes for this board (`$6000-$7FFF`, writable only while the MMC3's PRG-RAM is enabled and writable, INES_Mapper_047) was therefore never exercised by any real-game test -- exactly the part of the board the v2.9.6 tier note called unconfirmed. `InputScript::Presses { presses, frames, checkpoints }`: `(frame, buttons)` pairs, each held for exactly that one frame, strictly ascending (asserted), every other frame idle, `frames` frames in all; checkpoints and the frame-health bookkeeping behave as in the other scripts, and `total_frames()` reports `frames`, so the snapshot's `frames=` line stays exact. `extended_m47_nintendo_world_cup_block_1`: DOWN at frame 240, START at 260, 480 and 700, checkpoints 239, 300, 600 and 1,000. The dumped frames show the GAME SELECT screen (239), the Nintendo World Cup title (600) and its "Choose your team." screen (1,000): the second game, served from block 1. Mutant: the PRG block bit ignored (`(r(0) & 0) << 4` in mmc3_boards.rs's 47 arm) -- the snapshot differs and the test fails; the file was restored from a copy and the mutant's pending `.snap.new` removed. This is the evidence row for 47's D23 promotion in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../rustynes-test-harness/tests/common/mod.rs | 43 +++++++++++++++++++ .../tests/external_extended.rs | 30 +++++++++++++ ...tended_m47_nintendo_world_cup_block_1.snap | 15 +++++++ 3 files changed, 88 insertions(+) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m47_nintendo_world_cup_block_1.snap diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index 4379fab2..38a7a0f6 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -338,6 +338,18 @@ pub mod external { free_run: u32, checkpoints: &'static [u32], }, + /// v3.2.0: arbitrary single-frame presses. `presses` lists + /// `(frame, buttons)`, 0-indexed frames in ascending order, each + /// held for exactly that one frame; every other frame is idle, and + /// the run lasts `frames` frames in all. For a menu that needs more + /// than START (a multicart's game list needs DOWN first: mapper 47's + /// second game is reached that way, which is what exercises its + /// block-select register). + Presses { + presses: &'static [(u32, Buttons)], + frames: u32, + checkpoints: &'static [u32], + }, } impl InputScript { @@ -368,6 +380,7 @@ pub mod external { free_run, .. } => warmup + taps.saturating_sub(1) * period + 1 + free_run, + Self::Presses { frames, .. } => frames, } } } @@ -959,6 +972,36 @@ pub mod external { maybe_emit(frame_counter, &nes); } } + InputScript::Presses { + presses, + frames, + checkpoints: checkpoint_frames, + } => { + let mut maybe_emit = |frame: u32, nes: &Nes| { + if checkpoint_frames.contains(&frame) { + let fb_hash = fnv1a64(nes.framebuffer()); + best_health = Some(best_of(best_health, frame_health(nes.framebuffer()))); + checkpoints.push(Checkpoint { frame, fb_hash }); + dump_frame(&label_safe, &format!("f{frame}"), nes.framebuffer()); + } + }; + assert!( + presses.windows(2).all(|w| w[0].0 < w[1].0), + "Presses frames must be strictly ascending" + ); + for f in 0..frames { + let held = presses + .iter() + .find(|&&(at, _)| at == f) + .map_or(Buttons::empty(), |&(_, b)| b); + nes.set_buttons(0, held); + pulse_vs_coin!(); + nes.run_frame(); + samples.extend(nes.drain_audio()); + frame_counter += 1; + maybe_emit(frame_counter, &nes); + } + } } // Defensive: ensure the framebuffer is the canonical size. diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 83b1bb3f..90a51b5c 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1418,3 +1418,33 @@ fn extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu() { "extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu", ); } + +// ============================================================ +// v3.2.0 — tier promotions under D23 (`T-CURATED-EVIDENCE`) +// ============================================================ + +/// Mapper 47 (NES-QJ, *Super Spike V'Ball + Nintendo World Cup*): DOWN then +/// START on the cartridge's game-select screen picks the SECOND game, which +/// lives in the second 128 KiB block. Reaching it is what exercises the +/// block-select register at `$6000-$7FFF` (writable only while the MMC3's +/// PRG-RAM is enabled and writable, NESdev `INES_Mapper_047`); the default +/// coverage capture only ever starts the first game, block 0. +const M47_SECOND_GAME: InputScript = InputScript::Presses { + presses: &[ + (240, rustynes_core::Buttons::DOWN), + (260, rustynes_core::Buttons::START), + (480, rustynes_core::Buttons::START), + (700, rustynes_core::Buttons::START), + ], + frames: 1000, + checkpoints: &[239, 300, 600, 1000], +}; + +#[test] +fn extended_m47_nintendo_world_cup_block_1() { + check( + "mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) [!].nes", + M47_SECOND_GAME, + "extended_m47_nintendo_world_cup_block_1", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m47_nintendo_world_cup_block_1.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m47_nintendo_world_cup_block_1.snap new file mode 100644 index 00000000..a0ec72df --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m47_nintendo_world_cup_block_1.snap @@ -0,0 +1,15 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) [!].nes +rom_sha256=dbd8f793bd841cf967d499f80fd042b7abd40f7a6567db479ff081ccb984af4c +frames=1000 +fb_bytes=245760 +cycles=29750734 +audio_samples=733040 +audio_fnv1a64=e973e58678787867 +checkpoint f239 fb_fnv1a64=a2a8d678ac5bf0b9 +checkpoint f300 fb_fnv1a64=cb21c211dd38e481 +checkpoint f600 fb_fnv1a64=6f751aa110b85a54 +checkpoint f1000 fb_fnv1a64=eb2764fd5f2d71ed From 1727eb9c1977e55383702caf456ac2e18da4acfe Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 20:41:17 -0400 Subject: [PATCH 010/104] feat(mappers): promote 47, 121, 154, 191 and 243 to Curated under D23 v3.2.0 plan items 3 and 4 (`T-CURATED-EVIDENCE`, `T-DUMP-CORPUS`). D23: a dump that cannot be redistributed is Curated evidence when the record names the title, the hash, the frames and a committed screenshot. The new section "Curated evidence from local dumps (D23)" in docs/compatibility.md holds one row per family (SHA-256 of the image after the header, the key `Nes::rom_sha256` uses), and every frame was looked at before it was accepted: - 47: Super Spike V'Ball's menu, and (previous commit) Nintendo World Cup's team select through the block register. - 121: Ultimate Mortal Kombat 3 in a fight (four more titles staged). - 154: Devil Man (Japan), its opening cutscene. - 191: Mighty Final Fight's cutscene in its CHR-RAM font. - 243: Honey Peach, the one game NESdev lists for the SA-020A. Tier counts move from 51 / 109 / 31 to 51 / 114 / 26 (Core / Curated / BestEffort), measured by calling `mapper_tier` for 0..1024 (191 supported). tier.rs adds the five ids to the v2.9.6 Curated arm, with the evidence named per id in a comment inside the pattern, and moves 194 into the main BestEffort arm. (A separate arm per batch trips clippy's match_same_arms; the existing batch arms escaped it only because rustfmt wrote their bodies as blocks, and removing ids made one short enough to collapse, so the ids are merged rather than leaning on that formatting.) its tests' CURATED_IDS / BEST_EFFORT_IDS lists follow, and the v2.3.4 test for 154/243 now expects Curated. scripts/coverage/coverage.py's embedded fallback sets are regenerated (its parser read 51/114/26 live and warned that the fallback disagreed). `coverage.py categorize` moved the 47/121/191 screenshot directories from besteffort/ to external/. Item 4's gap, measured rather than assumed: `external_coverage` snapshots every staged dump, so "no recorded boot" means "no staged dump", and the supported families with a library dump and nothing staged were 154, 243, 268 and 290. All four are now staged in the gitignored corpus and blessed through scripts/coverage/bless.sh with RUSTYNES_COVERAGE_FILTER (290 in the previous commit, with its fix). 268 (Gardman, and a Chinese story screen) boots and stays BestEffort, not being on the promotion list. What is not evidence, recorded with its reason in the same section: the Sachen titles GoodNES headers as 243 (Strategist, Poker III, Chinese Checkers, Magic Cubes) are SA-015 games, mapper 150 by NESdev; staged under 243 they rendered either correctly by the coincidence of a small CHR or garbled (Poker III, Strategist), and were removed again. Poker III renders garbled under both decodes, so it stays an open finding (the plan's "Findings during the build"), as do the vendored game database's rows that route those titles to 243. Verified: rustynes-mappers tier tests 11/11; coverage blesses of the new families (the one insta "failure" per run is the new baseline being written, by design); a re-bless of 47/121/191 wrote no `.snap.new`, so the current core reproduces their existing baselines and the screenshots stand. `mapper_tier_honesty`'s corpus walk currently fails in THIS tree on dumps the parallel v3.2.0 mapper branches staged (114, 157) for families that only exist on those branches; it clears when they merge, and is not caused by this change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/tier.rs | 46 ++++++++++++------ ...per_154_Namco3453_Devil_Man_Japan_zip.snap | 13 +++++ ...henSA020A_Honey_Peach_Asia_Ja_Unl_zip.snap | 13 +++++ ..._268_BMC_Coolboy_Gardman_Asia_Unl_zip.snap | 13 +++++ ...Qi_Zhi_Xue_Hai_Piao_Ling_Asia_Unl_zip.snap | 13 +++++ docs/compatibility.md | 38 +++++++++++++++ screenshots/besteffort/README.md | 7 +++ .../Gardman (Asia) (Unl).png | Bin 0 -> 5285 bytes ... Qi Zhi Xue Hai Piao Ling (Asia) (Unl).png | Bin 0 -> 5764 bytes ...ndo World Cup (U) - Nintendo World Cup.png | Bin 0 -> 2464 bytes ... Spike V'Ball + Nintendo World Cup (U).png | Bin .../Mortal Kombat 6 (Unl).png | Bin ...da Prince, The (Shin-Shin) (Eng) (Unl).png | Bin .../Super 3-in-1 (NT-934) (Unl).png | Bin .../Ultimate Mortal Kombat 3 (Unl).png | Bin .../Yuu Yuu Hakusho 97 V (Eng) (Ch).png | Bin .../Devil Man (Japan).png | Bin 0 -> 3668 bytes ...ekketsu Monogatari (J) [T+Chi_madcell].png | Bin ...ayo Zenin Shuugou! (J) [T+ChS_axi,ahe].png | Bin ...Mighty Final Fight (J) [T+Chi_madcell].png | Bin ...ugoro Quest - Dice no Senshitachi (Ch).png | Bin .../Honey Peach (Asia) (Ja) (Unl).png | Bin 0 -> 4672 bytes scripts/coverage/coverage.py | 8 +-- to-dos/plans/v3.2.0-plan.md | 22 ++++++++- 24 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_154_Namco3453_Devil_Man_Japan_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_243_SachenSA020A_Honey_Peach_Asia_Ja_Unl_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Gardman_Asia_Unl_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Meng_Huan_Xiang_Shuai_Chuan_Qi_Zhi_Xue_Hai_Piao_Ling_Asia_Unl_zip.snap create mode 100644 screenshots/besteffort/mapper-268-BMC-Coolboy/Gardman (Asia) (Unl).png create mode 100644 screenshots/besteffort/mapper-268-BMC-Coolboy/Meng Huan - Xiang Shuai Chuan Qi Zhi Xue Hai Piao Ling (Asia) (Unl).png create mode 100644 screenshots/external/mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) - Nintendo World Cup.png rename screenshots/{besteffort => external}/mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U).png (100%) rename screenshots/{besteffort => external}/mapper-121-KashengA9711/Mortal Kombat 6 (Unl).png (100%) rename screenshots/{besteffort => external}/mapper-121-KashengA9711/Panda Prince, The (Shin-Shin) (Eng) (Unl).png (100%) rename screenshots/{besteffort => external}/mapper-121-KashengA9711/Super 3-in-1 (NT-934) (Unl).png (100%) rename screenshots/{besteffort => external}/mapper-121-KashengA9711/Ultimate Mortal Kombat 3 (Unl).png (100%) rename screenshots/{besteffort => external}/mapper-121-KashengA9711/Yuu Yuu Hakusho 97 V (Eng) (Ch).png (100%) create mode 100644 screenshots/external/mapper-154-Namco3453/Devil Man (Japan).png rename screenshots/{besteffort => external}/mapper-191-Waixing191/Downtown - Nekketsu Monogatari (J) [T+Chi_madcell].png (100%) rename screenshots/{besteffort => external}/mapper-191-Waixing191/Downtown Special - Kunio-kun no Jidaigeki Dayo Zenin Shuugou! (J) [T+ChS_axi,ahe].png (100%) rename screenshots/{besteffort => external}/mapper-191-Waixing191/Mighty Final Fight (J) [T+Chi_madcell].png (100%) rename screenshots/{besteffort => external}/mapper-191-Waixing191/Sugoro Quest - Dice no Senshitachi (Ch).png (100%) create mode 100644 screenshots/external/mapper-243-SachenSA020A/Honey Peach (Asia) (Ja) (Unl).png diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index e46b5354..f164049a 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -132,7 +132,22 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // GTROM (111), promoted from BestEffort once the board matched its // page (register window, bonus RAM, self-flashing) and a CC0 fixture // pinned it. None has a redistributable commercial ROM. - 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => { + 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 + // --- v3.2.0 D23 promotions (`T-CURATED-EVIDENCE`): a local dump counts + // as Curated evidence when the record names the title, its hash, the + // frames and a committed screenshot (`docs/compatibility.md`, "Curated + // evidence from local dumps"). Each boots to a correct picture in the + // byte-identity coverage harness: + // 47: Super Spike V'Ball's menu, AND (`extended_m47_...`) Nintendo + // World Cup's team select, reached through the block register + // its page says is writable only with PRG-RAM enabled; + // 121: Ultimate Mortal Kombat 3 in a fight, four other titles; + // 154: Devil Man (Japan), its opening cutscene; + // 191: Mighty Final Fight's cutscene with its CHR-RAM font; + // 243: Honey Peach, the one game NESdev lists for the SA-020A (the + // Sachen titles GoodNES headers as 243 are SA-015 games, mapper + // 150, and are not evidence for this board). + | 47 | 121 | 154 | 191 | 243 => { Some(MapperTier::Curated) } @@ -156,15 +171,16 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // database, which is what routes `Devil Man` from its m88 header to 154 // and the Sachen 74LS374N set from m150 to 243. Their dumps are staged // but not redistributable, so neither can be honestly oracle-gated. - 29 | 39 | 50 | 81 | 104 | 154 | 174 | 179 | 238 | 243 | 261 | 268 | 286 | 289 | 290 - | 299 | 301 | 303 | 305 | 306 | 312 | 320 | 336 | 348 | 349 | 366 | 513 => { + // v3.2.0: both moved to Curated under D23 (above). + 29 | 39 | 50 | 81 | 104 | 174 | 179 | 238 | 261 | 268 | 286 | 289 | 290 | 299 | 301 + | 303 | 305 | 306 | 312 | 320 | 336 | 348 | 349 | 366 | 513 + // --- v2.9.6 "Roster" BestEffort: pages that give only Disch's notes + // (194; 47 and 191 until v3.2.0) or register masks marked "probably" + // (121 until v3.2.0). + | 194 => { Some(MapperTier::BestEffort) } - // --- v2.9.6 "Roster" BestEffort: pages that give only Disch's notes - // (47, 191, 194) or register masks marked "probably" (121). - 47 | 121 | 191 | 194 => Some(MapperTier::BestEffort), - _ => None, } } @@ -192,15 +208,11 @@ mod tests { /// 154 and 243 landed in v2.3.4 and must be classified, or `parse` supports /// a mapper the tier table does not know -- the invariant this module exists - /// to hold. + /// to hold. `BestEffort` until v3.2.0, `Curated` since (D23). #[test] - fn v234_additions_are_classified_best_effort() { + fn v234_additions_are_classified() { for id in [154u16, 243] { - assert_eq!( - mapper_tier(id, 0), - Some(MapperTier::BestEffort), - "mapper {id}" - ); + assert_eq!(mapper_tier(id, 0), Some(MapperTier::Curated), "mapper {id}"); } } @@ -243,6 +255,8 @@ mod tests { 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, + // v3.2.0 D23 promotions. + 47, 121, 154, 191, 243, ]; #[test] @@ -268,8 +282,8 @@ mod tests { /// NTDEC/TXC/BMC multicart batch (193/204/221/299). const BEST_EFFORT_IDS: &[u16] = &[ 29, 39, 50, 81, 104, 174, 179, 238, 261, 268, 286, 289, 290, 299, 301, 303, 305, 306, 312, - 320, 336, 348, 349, 366, 513, // v2.9.6 "Roster": - 47, 121, 191, 194, + 320, 336, 348, 349, 366, 513, // v2.9.6 "Roster" (47, 121, 191 Curated since v3.2.0): + 194, ]; #[test] diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_154_Namco3453_Devil_Man_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_154_Namco3453_Devil_Man_Japan_zip.snap new file mode 100644 index 00000000..9a0c91d7 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_154_Namco3453_Devil_Man_Japan_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-154-Namco3453/Devil Man (Japan).zip +rom_sha256=1db92db5d8535e0ca2653eb703e0dbb5f99b9fc737b2833d6e637333293b5ea3 +frames=1141 +fb_bytes=245760 +cycles=33949774 +audio_samples=836505 +audio_fnv1a64=ef96783dc57335ea +checkpoint f900 fb_fnv1a64=ab39e904f9009c69 +checkpoint f1100 fb_fnv1a64=ade23dd97248d2eb diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_243_SachenSA020A_Honey_Peach_Asia_Ja_Unl_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_243_SachenSA020A_Honey_Peach_Asia_Ja_Unl_zip.snap new file mode 100644 index 00000000..e775e008 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_243_SachenSA020A_Honey_Peach_Asia_Ja_Unl_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-243-SachenSA020A/Honey Peach (Asia) (Ja) (Unl).zip +rom_sha256=d33d67973b57bc5d7dc2ae7d2cadc0d12113be1283da7902d5e6fb0c888953d1 +frames=1141 +fb_bytes=245760 +cycles=33949771 +audio_samples=836505 +audio_fnv1a64=2f388fdb12509ceb +checkpoint f900 fb_fnv1a64=35b57521f08be6af +checkpoint f1100 fb_fnv1a64=58ccc42434a4025f diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Gardman_Asia_Unl_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Gardman_Asia_Unl_zip.snap new file mode 100644 index 00000000..624fd754 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Gardman_Asia_Unl_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-268-BMC-Coolboy/Gardman (Asia) (Unl).zip +rom_sha256=411022972a0a698213952713a6c72cfb431e43ee81f83dd2938f04fcaf8dd6d9 +frames=1141 +fb_bytes=245760 +cycles=40428961 +audio_samples=996152 +audio_fnv1a64=88778486824cd0c2 +checkpoint f900 fb_fnv1a64=a6275cc1503a5b59 +checkpoint f1100 fb_fnv1a64=56e11f69781192da diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Meng_Huan_Xiang_Shuai_Chuan_Qi_Zhi_Xue_Hai_Piao_Ling_Asia_Unl_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Meng_Huan_Xiang_Shuai_Chuan_Qi_Zhi_Xue_Hai_Piao_Ling_Asia_Unl_zip.snap new file mode 100644 index 00000000..408085b6 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_268_BMC_Coolboy_Meng_Huan_Xiang_Shuai_Chuan_Qi_Zhi_Xue_Hai_Piao_Ling_Asia_Unl_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-268-BMC-Coolboy/Meng Huan - Xiang Shuai Chuan Qi Zhi Xue Hai Piao Ling (Asia) (Unl).zip +rom_sha256=22657034b599fe44000259bdd2cb590dede9ec665cd16f34497be1ae57f2e3d4 +frames=1141 +fb_bytes=245760 +cycles=40428961 +audio_samples=996152 +audio_fnv1a64=0d3758b0913e0249 +checkpoint f900 fb_fnv1a64=0e474a0a51a907b2 +checkpoint f1100 fb_fnv1a64=f0c204fc999ac9a6 diff --git a/docs/compatibility.md b/docs/compatibility.md index 73137556..ef954af3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -362,3 +362,41 @@ FE-11, which the v3.1 → v4.0 line plan does not schedule: - **CRC32 vs. SHA-256 for ROM identification.** We use SHA-256 for save state directory naming (lower collision risk). For ROM compatibility databases, CRC32 is the community standard; we may add it as a secondary key. - **Region override.** Some users want to play PAL versions of NTSC games at NTSC speed. Plan: expose a region override in the settings UI; warn that this may break timing-sensitive ROMs. + +## Curated evidence from local dumps (D23) + +A mapper family whose only evidence is a dump that cannot be redistributed may +still be **Curated** (ADR 0011) when that dump boots to a correct picture and +this table records it (maintainer decision D23, v3.2.0 `T-CURATED-EVIDENCE`): +the title, the SHA-256 of the image after its 16-byte header (the key +`Nes::rom_sha256` uses), the frames the capture ran, and a committed screenshot. +The capture is the byte-identity `external_coverage` boot (1,141 frames: +START tapped five times from frame 240, every 150 frames, then 300 free) unless +the row names an `external_extended` test. The dumps live in the gitignored +`tests/roms/external/`; only the hash, the snapshot and the screenshot are +committed. + +| mapper | title | SHA-256 after the header | frames | screenshot | +| --- | --- | --- | --- | --- | +| 47 | Super Spike V'Ball + Nintendo World Cup (U) | `8951dccb6640b48c857f321ef01c06110c5b8870f5d901dcc538ab1b8db7e2a1` | 1,141 | `screenshots/external/mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U).png` (the first game's menu) | +| 47 | the same dump, second game | same | 1,000 (`extended_m47_nintendo_world_cup_block_1`: DOWN at 240, START at 260, 480, 700) | `screenshots/external/mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) - Nintendo World Cup.png` (its team select, from the second 128 KiB block) | +| 121 | Ultimate Mortal Kombat 3 (Unl) | `040c7b6497754415172735a6dc9e3d6812cb9caef58a8f49ae48b72bfcc435e6` | 1,141 | `screenshots/external/mapper-121-KashengA9711/Ultimate Mortal Kombat 3 (Unl).png` (a fight) | +| 154 | Devil Man (Japan) | `d2140fc2e6354a9f4d0154dabac757e5559890edba4885799c1c979d8b7a8b20` | 1,141 | `screenshots/external/mapper-154-Namco3453/Devil Man (Japan).png` (the opening cutscene) | +| 191 | Mighty Final Fight (J) [T+Chi] | `671f372fa92819d02147cae9072139f1193ebcc22030db3d09c61a768fdaec17` | 1,141 | `screenshots/external/mapper-191-Waixing191/Mighty Final Fight (J) [T+Chi_madcell].png` (a cutscene in its CHR-RAM font) | +| 243 | Honey Peach (Asia) (Ja) (Unl) | `4342d7b1bbd3b263dfc47e3e3cd51abdf0d98f826a89ec9bc67db35a1c2ad871` | 1,141 | `screenshots/external/mapper-243-SachenSA020A/Honey Peach (Asia) (Ja) (Unl).png` (its play screen: both players, the hand symbols) | + +**Not evidence, and why:** + +- **The Sachen titles GoodNES headers as mapper 243** (*Strategist*, *Poker + III*, *Chinese Checkers*, *Magic Cubes* and others) are SA-015 games, mapper + 150 (`nesdev_wiki/INES_Mapper_150.xhtml` lists them; 243's page names *Honey + Peach* alone). The two board layouts read the same ASIC's CHR bits at + different significance, so a 150 game run as 243 renders correctly only when + its CHR fits the bits the two share, and *Poker III* and *Strategist* render + garbled. The vendored game database also routes most of these titles to 243. + *Poker III* renders garbled under both decodes, so it is an open finding + recorded in the v3.2.0 plan, not a routing fix. +- **290** (*Asder 20-in-1*) booted to a uniform grey frame until v3.2.0 fixed its + 32 KiB PRG mode; it now starts a game, but the capture shows a top band of + scrambled tiles that nobody has compared with hardware (NESdev notes one game + on the cart glitches in attract mode on a real console). It stays BestEffort. diff --git a/screenshots/besteffort/README.md b/screenshots/besteffort/README.md index 4fb7abb0..fb71b5fc 100644 --- a/screenshots/besteffort/README.md +++ b/screenshots/besteffort/README.md @@ -1,5 +1,12 @@ # BestEffort mapper boot-smoke screenshots (v1.3.0 D1) +> **v3.2.0: two directories, 268 and 290.** 47, 121 and 191 moved to +> `external/` when D23 promoted them to Curated, with 154 and 243. 268 (*Gardman*, +> a Chinese story screen) and 290 (*Asder 20-in-1*, which booted to a uniform grey +> frame until v3.2.0 fixed its 32 KiB PRG mode) arrived here in v3.2.0 and stay +> BestEffort: 268 is not on the promotion list, and 290's capture shows a top band +> of scrambled tiles nobody has confirmed against hardware. +> > **v2.9.8: three directories, all from v2.9.6's families.** Every family that > had a capture here before has since been promoted to Core or Curated (the > v2.1.0 "Fathom" sweep, then GTROM in v2.9.6), and `coverage.py categorize` diff --git a/screenshots/besteffort/mapper-268-BMC-Coolboy/Gardman (Asia) (Unl).png b/screenshots/besteffort/mapper-268-BMC-Coolboy/Gardman (Asia) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..4509a1f3c19816b4e110d1cda198097038d0eda2 GIT binary patch literal 5285 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 Jy3`UG^&hI2thoRH literal 0 HcmV?d00001 diff --git a/screenshots/besteffort/mapper-268-BMC-Coolboy/Meng Huan - Xiang Shuai Chuan Qi Zhi Xue Hai Piao Ling (Asia) (Unl).png b/screenshots/besteffort/mapper-268-BMC-Coolboy/Meng Huan - Xiang Shuai Chuan Qi Zhi Xue Hai Piao Ling (Asia) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..970f476230aa6d1adadbca4ce7cce0040c8e8683 GIT binary patch literal 5764 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 Jt1fsZ{ReoOqR#*T literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) - Nintendo World Cup.png b/screenshots/external/mapper-047-SpikeVBall-NWC/Super Spike V'Ball + Nintendo World Cup (U) - Nintendo World Cup.png new file mode 100644 index 0000000000000000000000000000000000000000..ed40ab9cd73f4b97bdd2907a75036247c209cdf9 GIT binary patch literal 2464 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 DCiH8l&vI%p-m}FgR-`eeVK_wi7aI)v!KY9B} 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 str: # embedded fallback (absorbs categorize_screenshots' static table). # --------------------------------------------------------------------------- # -# Regenerated from tier.rs at v2.9.8 (51 / 109 / 31); used only if that -# file cannot be parsed. +# Regenerated from tier.rs at v3.2.0 (51 / 114 / 26: 47, 121, 154, 191 and +# 243 promoted under D23); used only if that file cannot be parsed. _FALLBACK_CORE = { 0, 1, 2, 3, 4, 5, 7, 9, 10, 11, 13, 16, 18, 19, 21, 22, 23, 24, 25, 26, 32, 33, 34, 48, 64, 65, 66, 67, 68, 69, 70, 71, 73, 75, 78, 80, 82, 85, @@ -458,10 +458,10 @@ def mapper_dir_name(mapper: int) -> str: 156, 162, 163, 164, 176, 177, 178, 180, 185, 189, 192, 193, 195, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 228, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 249, - 250, 253, + 250, 253, 47, 121, 154, 191, 243, } _FALLBACK_BEST_EFFORT = { - 29, 39, 47, 50, 81, 104, 121, 154, 174, 179, 191, 194, 238, 243, 261, + 29, 39, 50, 81, 104, 174, 179, 194, 238, 261, 268, 286, 289, 290, 299, 301, 303, 305, 306, 312, 320, 336, 348, 349, 366, 513, } diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index 18cb1e53..29f7708c 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -48,12 +48,32 @@ until the hardware release shipped.) The codename is asked at the cut. (measured against `mapper_tier` itself, 191 supported). All four are also in item 3, which absorbs them. +## Findings during the build + +- **Mapper 290 never booted.** Its 32 KiB PRG mode -- the power-on mode -- read + `$C000-$FFFF` through the 16 KiB window, so the reset vector came from the + wrong half and *Asder 20-in-1* showed a uniform grey frame. Fixed red-first + (`m290_prg_modes_follow_the_size_bit`); it changes output against v3.1.0, so + `EMULATION_EPOCH` is 4. 290 stays BestEffort: the new capture shows a top band + of scrambled tiles nobody has compared with hardware. +- **GoodNES's mapper-243 Sachen titles are mapper 150.** NESdev lists + *Strategist*, *Poker III 5-in-1*, *Chinese Checkers*, *Magic Cube* and others + as SA-015 (150); 243 is *Honey Peach* alone. The vendored game database also + routes them to 243. Those staged under 243 were removed again; 243's evidence + is *Honey Peach*. **Open:** *Poker III* renders garbled under both decodes, + and the database rows for the SA-015 titles disagree with NESdev. Neither is + fixed in v3.2.0 so far; both are recorded in `docs/compatibility.md`. +- **Mapper 47's second game is reachable.** A new `InputScript::Presses` + (arbitrary single-frame presses) drives DOWN then START on the cart's game + select, which reaches *Nintendo World Cup* through the block register; a + mutant that ignores the block bit fails `extended_m47_nintendo_world_cup_block_1`. + ## Sprints | Sprint | Deliverable | Gate | Status | | --- | --- | --- | --- | | 3.2-S1 | Items 1 and 4 (oracle families and the corpus) | rows 1, 4 | — | -| 3.2-S2 | Items 2 and 3 | rows 2, 3 | — | +| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 in progress | | 3.2-S3 | Item 5, the sibling options, first | row 5 | — | | 3.2-S4 | Items 6 and 7, the sibling mappers | rows 6, 7 | — | | 3.2-S5 | Item 8, then the seed sweep and the release cut | row 8; the sweep; both ladders | — | From 9bb3d5e205df55f7f39ba0f016bc6c413d153cef Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 20:42:47 -0400 Subject: [PATCH 011/104] feat(mappers): add mapper 197 (MMC3 with 2 / 4 KiB CHR banks) Mapper 197 is an MMC3 clone wired for 512 KiB of CHR-ROM by doubling the CHR bank granularity. Written from the vendored INES_Mapper_197.md into mmc3_boards_ext.rs; no reference-emulator source was read. The model is the page's wiring, not its bank table The page lists, per submapper, which PPU lines drive the MMC3's PA10-12 and that the MMC3's CHR A10-A17 drive CHR-ROM A11-A18 while PPU A10 drives ROM A10. For each pattern-table access the board builds the address the MMC3 sees (PA10 = PPU A11; PA11 = GND on submappers 0 and 3, Vcc on 1, PPU A11 on 2; PA12 = PPU A12), asks the MMC3 for the 1 KiB bank it would select there (Mmc3::chr_bank_1k), and puts that bank on ROM A11-A18 with PPU A10 below it. The page's three result tables (submapper 0: R0 as 4 KiB, R2 / R3 as 2 KiB; submapper 1: R1, R4, R5; submapper 2: R0 & $FE, R1 | 1, R2, R5) follow from this and are pinned row by row. Because the wiring is modelled, $8000 bit 7 (CHR A12 inversion) keeps working as it would on the MMC3 inside the cartridge, which the table form would have dropped; the page does not mention it. PA12 = PPU A12, so the A12 IRQ counter is unchanged. Submapper 3 adds .... S..P at $6000-$7FFF (mask $E000): S set makes PRG A17 = P. "As the outer bank register uses the MMC3 clone's WRAM interface, writes only have an effect if WRAM is enabled and not write-protected via $A001", so the write is gated on prg_ram_writable. PRG, mirroring and IRQs "function as in mapper 4": the Sharp default. iNES 1.0 images, and any NES 2.0 submapper above 3, are wired as submapper 0. The page identifies its submapper 1 and 2 games by PRG CRC32; that is per-game database work and is not done here. Verification - Red first: a parse test of 197.0-197.3 failed with the id unsupported, then passed. - 6 unit tests: the three wiring tables row by row (with odd R0/R1 so "AND $FE" / "OR $01" are visible); CHR inversion swapping the pattern tables; mapper 4 PRG, mirroring and Sharp IRQ; submapper 3's register dropped while WRAM is disabled or write-protected, S=1/P=0, S=1/P=1, S=0, and absent on submapper 0; state round trip, variant refusal and an unknown submapper wired as 0. - Boot snapshot, 600 frames idle: Super Fighter III (Unl) [!] (PRG CRC32 C333F621, the page's submapper 0 copy) boots to its title (SUPER FIGHTER III logo, PUSH START TO PLAY, the EASY 01234567 HARD selector). A probe pressing START reaches the world-map character select with all nine portraits drawn cleanly (not snapshotted). Screenshot and D23 row committed. - Tier: Curated on the D23 record. - Mutation: submapper 0 wired with PA11 = Vcc (submapper 1's wiring). Caught by the wiring-table test ("sub 0 at 0x0000", left 64 right 32) and by the boot snapshot (f600 hash 635fdbbf... -> b2a69d4d...). Restored from a copy. Not verified: submappers 1, 2 and 3 have no staged dump; they are unit-tested only. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 7 + .../rustynes-mappers/src/mmc3_boards_ext.rs | 84 +++++++++- .../src/mmc3_boards_ext_tests.rs | 146 ++++++++++++++++++ crates/rustynes-mappers/src/tier.rs | 4 +- .../tests/external_extended.rs | 10 ++ ...nded__extended_m197_super_fighter_iii.snap | 12 ++ docs/compatibility.md | 1 + docs/mappers.md | 1 + .../Super Fighter III (Unl) [!].png | Bin 0 -> 5207 bytes 9 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m197_super_fighter_iii.snap create mode 100644 screenshots/external/mapper-197-SuperFighter3/Super Fighter III (Unl) [!].png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 5afea0fe..bb911dd5 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1411,6 +1411,13 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { &h, )?, 182 => mmc3_ext(Mmc3ExtBoardKind::M114 { sub1: false }, prg_rom, chr_rom, &h)?, + // MMC3 with 2 / 4 KiB CHR granularity; the submapper is the wiring. + 197 => mmc3_ext( + Mmc3ExtBoardKind::M197 { sub: h.submapper }, + prg_rom, + chr_rom, + &h, + )?, // Sugar Softec UNL-8237 (215.0) / UNL-8237A (215.1). 215 => mmc3_ext( Mmc3ExtBoardKind::M215 { diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext.rs b/crates/rustynes-mappers/src/mmc3_boards_ext.rs index df93ec65..11ad39c1 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext.rs @@ -1,7 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! MMC3-clone boards added in v3.2.0, written from their NESdev pages: //! mappers 114 and 182 (the SuperGame / Hosenkan register scramblers) and -//! 215 (Sugar Softec's selectable scrambler, UNL-8237 / UNL-8237A). +//! 215 (Sugar Softec's selectable scrambler, UNL-8237 / UNL-8237A), and 197 +//! (an MMC3 rewired for 2 / 4 KiB CHR banks). //! //! These are, like the boards in `mmc3_boards.rs`, a stock MMC3 with a //! board-specific layer in front of it or behind it. The difference that @@ -73,6 +74,38 @@ //! substitution unordered, `C` is applied last, to whichever bank (MMC3 or //! NROM) is in force: it is described as a substitution of the PRG A17 line, //! which both sources drive. +//! +//! ## Mapper 197 (`INES_Mapper_197.md`) +//! +//! An MMC3 clone wired for 512 KiB of CHR-ROM by doubling the CHR bank +//! granularity: PPU A10 goes straight to CHR-ROM A10, the MMC3's CHR outputs +//! A10-A17 drive CHR-ROM A11-A18, and the MMC3's own PPU-address inputs are +//! rewired so that it sees PPU A11 on its PA10. The page gives the wiring per +//! submapper and the resulting bank table; this model implements the +//! *wiring*, not the table: +//! +//! | Sub | MMC3 PA10 | MMC3 PA11 | MMC3 PA12 | +//! |-----|-----------|-----------|-----------| +//! | 0, 3 | PPU A11 | GND | PPU A12 | +//! | 1 | PPU A11 | Vcc | PPU A12 | +//! | 2 | PPU A11 | PPU A11 | PPU A12 | +//! +//! For a pattern-table address the board forms the address the MMC3 sees, +//! asks the MMC3 which 1 KiB bank it would select there +//! ([`Mmc3::chr_bank_1k`]), and builds the ROM address as that bank on +//! A11-A18 with PPU A10 below it. The page's tables (submapper 0: R0 as +//! 4 KiB at `$0000`, R2 / R3 as 2 KiB at `$1000` / `$1800`, R1, R4 and R5 +//! unused) fall out of this, and so does `$8000` bit 7 (the CHR A12 +//! inversion), which the page does not mention: the MMC3 still has it, and +//! with PA12 wired to PPU A12 it still swaps which registers each pattern +//! table sees. PA12 is unchanged, so the A12 IRQ counter is the MMC3's. +//! +//! Submapper 3 adds an outer register at `$6000-$7FFF` (mask `$E000`) on the +//! MMC3's WRAM interface (taken only while `$A001` allows a WRAM write): +//! `.... S..P`, with `S` set making PRG A17 = `P` (128 KiB) instead of the +//! MMC3's A17. iNES 1.0 images take submapper 0. The page names a PRG CRC32 +//! for its one submapper 1 and one submapper 2 game; detecting them is the +//! per-game database's job, not the board's. #![allow( clippy::cast_possible_truncation, @@ -113,6 +146,12 @@ pub enum ExtBoard { /// NES 2.0 submapper 1 (UNL-8237A). sub1: bool, }, + /// Mapper 197, `INES_Mapper_197.md`: the PPU-address wiring of + /// submappers 0-3 (any other value is taken as 0). + M197 { + /// NES 2.0 submapper. + sub: u8, + }, } impl ExtBoard { @@ -120,6 +159,7 @@ impl ExtBoard { match self { Self::M114 { .. } => 114, Self::M215 { .. } => 215, + Self::M197 { .. } => 197, } } @@ -129,6 +169,10 @@ impl ExtBoard { Self::M114 { sub1: true } => "SuperGame MMC3 scramble (114.1)", Self::M215 { sub1: false } => "Sugar Softec UNL-8237 (215.0)", Self::M215 { sub1: true } => "Sugar Softec UNL-8237A (215.1)", + Self::M197 { sub: 1 } => "MMC3 2/4 KiB CHR (197.1)", + Self::M197 { sub: 2 } => "MMC3 2/4 KiB CHR (197.2)", + Self::M197 { sub: 3 } => "MMC3 2/4 KiB CHR + outer PRG (197.3)", + Self::M197 { .. } => "MMC3 2/4 KiB CHR (197.0)", } } @@ -140,6 +184,7 @@ impl ExtBoard { Self::M114 { sub1: true } => 1, Self::M215 { sub1: false } => 2, Self::M215 { sub1: true } => 3, + Self::M197 { sub } => 4 + sub, } } } @@ -260,6 +305,7 @@ pub struct Mmc3BoardExt { wram: Box<[u8]>, /// Board registers. 114: `regs[0]` = `$6000`, `regs[1]` = `$6001`. /// 215: `regs[0]` = `$5000`, `regs[1]` = `$5001`, `regs[2]` = `$5007`. + /// 197.3: `regs[0]` = the `$6000` outer register. regs: [u8; 4], } @@ -300,7 +346,14 @@ impl Mmc3BoardExt { // of zero disables the IRQ, on which Aladdin depends." let revision = match board { ExtBoard::M114 { .. } => Mmc3Revision::Nec, - ExtBoard::M215 { .. } => Mmc3Revision::Sharp, + // 197: "PRG-ROM, nametable mirroring and IRQs function as in + // mapper 4." + ExtBoard::M215 { .. } | ExtBoard::M197 { .. } => Mmc3Revision::Sharp, + }; + // Unknown 197 submappers are wired as submapper 0. + let board = match board { + ExtBoard::M197 { sub } if sub > 3 => ExtBoard::M197 { sub: 0 }, + b => b, }; let mut regs = [0u8; 4]; if matches!(board, ExtBoard::M215 { .. }) { @@ -339,6 +392,11 @@ impl Mmc3BoardExt { }; inner | (hi << 5) } + // 197.3: `S` (bit 4) set makes PRG A17 = `P` (bit 0). + ExtBoard::M197 { sub: 3 } if self.regs[0] & 0x10 != 0 => { + (raw & 0x0F) | (usize::from(self.regs[0] & 1) << 4) + } + ExtBoard::M197 { .. } => raw, } } @@ -364,6 +422,19 @@ impl Mmc3BoardExt { }; inner | (hi << 8) } + ExtBoard::M197 { sub } => { + // The address the MMC3 sees: PA10 = PPU A11, PA11 per the + // submapper's wiring, PA12 = PPU A12. + let a11 = (addr >> 11) & 1; + let pa11 = match sub { + 1 => 1, + 2 => a11, + _ => 0, + }; + let seen = (addr & 0x1000) | (pa11 << 11) | (a11 << 10); + // MMC3 CHR A10-A17 drive ROM A11-A18; PPU A10 drives ROM A10. + ((self.core.chr_bank_1k(seen) & 0xFF) << 1) | usize::from((addr >> 10) & 1) + } } } @@ -380,6 +451,7 @@ impl Mmc3BoardExt { ExtBoard::M215 { .. } => { unscramble(&M215_SCRAMBLE[usize::from(self.regs[2] & 7)], addr, value) } + ExtBoard::M197 { .. } => (addr, value), }; self.core.cpu_write(addr, value); } @@ -448,6 +520,14 @@ impl Mapper for Mmc3BoardExt { 0x5007 => self.regs[2] = value & 7, _ => {} }, + (ExtBoard::M197 { sub: 3 }, 0x6000..=0x7FFF) if self.core.prg_ram_writable() => { + // On the WRAM interface: only while `$A001` allows a write. + self.regs[0] = value; + if !self.wram.is_empty() { + let len = self.wram.len(); + self.wram[usize::from(addr - 0x6000) % len] = value; + } + } (_, 0x6000..=0x7FFF) if !self.wram.is_empty() && self.core.prg_ram_writable() => { let len = self.wram.len(); self.wram[usize::from(addr - 0x6000) % len] = value; diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs index 2083253e..84c50cef 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs @@ -464,3 +464,149 @@ fn m215_state_round_trip_and_variant_refusal() { assert!(board(M215_1, 64, 512).load_state(&blob).is_err()); assert!(board(M114_0, 64, 512).load_state(&blob).is_err()); } + +// --------------------------------------------------------------------------- +// Mapper 197 (`INES_Mapper_197.md`). +// --------------------------------------------------------------------------- + +#[test] +fn m197_parses() { + for sub in [0u8, 1, 2, 3] { + assert!( + crate::parse(&nes2_image(197, sub, 16, 64)).is_ok(), + "mapper 197.{sub} must construct" + ); + } +} + +/// Load R0-R5 with `0x11, 0x21, 0x30, 0x40, 0x50, 0x60` (R0/R1 odd, so the +/// page's `AND $FE` / `OR $01` are visible) and R6 with 3. +fn m197_load_regs(m: &mut Mmc3BoardExt, mode: u8) { + for (r, v) in [ + (0u8, 0x11u8), + (1, 0x21), + (2, 0x30), + (3, 0x40), + (4, 0x50), + (5, 0x60), + (6, 3), + ] { + m.cpu_write(0x8000, mode | r); + m.cpu_write(0x8001, v); + } +} + +/// The page's "Real PPU Addr -> MMC3 reg" table for each submapper, row by +/// row. A row's MMC3 register value lands on CHR-ROM A11-A18 and PPU A10 on +/// A10, so the 1 KiB ROM bank is `value * 2 + A10`. +#[test] +fn m197_chr_wiring_follows_the_page_tables() { + let (r0, r1, r2, r3, r4, r5) = (0x11usize, 0x21, 0x30, 0x40, 0x50, 0x60); + let tables: [(u8, [usize; 4]); 3] = [ + // sub 0: $0000/$0400 R0&$FE, $0800/$0C00 R0|1, $1000/$1400 R2, $1800/$1C00 R3. + (0, [r0 & 0xFE, r0 | 1, r2, r3]), + // sub 1: R1&$FE, R1|1, R4, R5. + (1, [r1 & 0xFE, r1 | 1, r4, r5]), + // sub 2: R0&$FE, R1|1, R2, R5. + (2, [r0 & 0xFE, r1 | 1, r2, r5]), + ]; + for (sub, regs) in tables { + let mut m = board(ExtBoard::M197 { sub }, 16, 512); + m197_load_regs(&mut m, 0); + for (i, &v) in regs.iter().enumerate() { + let base = (i as u16) * 0x800; + assert_eq!(chr_at(&mut m, base), v * 2, "sub {sub} at {base:#06x}"); + assert_eq!( + chr_at(&mut m, base + 0x400), + v * 2 + 1, + "sub {sub} at {:#06x}", + base + 0x400 + ); + } + } +} + +/// `$8000` bit 7 is not on the page, but the MMC3 still has it and PA12 is +/// PPU A12, so it swaps which registers each pattern table sees: inverted, +/// submapper 0's `$0000` reads R2 and `$1000` reads R0. +#[test] +fn m197_chr_inversion_still_swaps_pattern_tables() { + let mut m = board(ExtBoard::M197 { sub: 0 }, 16, 512); + m197_load_regs(&mut m, 0x80); + assert_eq!(chr_at(&mut m, 0x0000), 0x30 * 2); + assert_eq!(chr_at(&mut m, 0x0800), 0x40 * 2); + assert_eq!(chr_at(&mut m, 0x1000), 0x10 * 2); + assert_eq!(chr_at(&mut m, 0x1800), 0x11 * 2); +} + +/// "PRG-ROM, nametable mirroring and IRQs function as in mapper 4": R6 at +/// `$8000`, `$A000` mirroring, and the Sharp IRQ (a latch of 0 fires every +/// clock). +#[test] +fn m197_prg_mirroring_and_irq_are_mapper_4() { + let mut m = board(ExtBoard::M197 { sub: 0 }, 16, 512); + m197_load_regs(&mut m, 0); + assert_eq!(prg_at(&mut m, 0x8000), 3); + assert_eq!(prg_at(&mut m, 0xE000), 15); + m.cpu_write(0xA000, 1); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + m.cpu_write(0xC000, 0); + m.cpu_write(0xC001, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + m.cpu_write(0xE000, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + assert!(m.irq_pending()); +} + +/// Submapper 3: `$6000-$7FFF` `.... S..P` on the WRAM interface. With S set +/// PRG A17 = P; with S clear it is the MMC3's. A write while `$A001` forbids +/// it is dropped. Submapper 0 has no such register. +#[test] +fn m197_sub3_outer_prg_register() { + let mut m = board(ExtBoard::M197 { sub: 3 }, 32, 512); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x13); + m.cpu_write(0xA001, 0x00); // WRAM disabled + m.cpu_write(0x6000, 0x10); + assert_eq!(prg_at(&mut m, 0x8000), 0x13, "dropped while disabled"); + m.cpu_write(0xA001, 0xC0); // enabled, write-protected + m.cpu_write(0x7FFF, 0x10); + assert_eq!(prg_at(&mut m, 0x8000), 0x13, "dropped while protected"); + m.cpu_write(0xA001, 0x80); + m.cpu_write(0x7FFF, 0x10); + assert_eq!(prg_at(&mut m, 0x8000), 0x03, "S=1, P=0"); + m.cpu_write(0x6000, 0x11); + m.cpu_write(0x8001, 0x03); + assert_eq!(prg_at(&mut m, 0x8000), 0x13, "S=1, P=1"); + m.cpu_write(0x6000, 0x01); + assert_eq!(prg_at(&mut m, 0x8000), 0x03, "S=0: the MMC3's A17"); + + let mut m = board(ExtBoard::M197 { sub: 0 }, 32, 512); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x03); + m.cpu_write(0xA001, 0x80); + m.cpu_write(0x6000, 0x11); + assert_eq!(prg_at(&mut m, 0x8000), 0x03, "sub 0 has no outer register"); +} + +#[test] +fn m197_state_round_trip_and_variant_refusal() { + let mut m = board(ExtBoard::M197 { sub: 3 }, 32, 512); + m.cpu_write(0xA001, 0x80); + m.cpu_write(0x6000, 0x11); + m197_load_regs(&mut m, 0); + let blob = m.save_state(); + let mut n = board(ExtBoard::M197 { sub: 3 }, 32, 512); + n.load_state(&blob).expect("same variant"); + assert_eq!(n.save_state(), blob); + assert!( + board(ExtBoard::M197 { sub: 0 }, 32, 512) + .load_state(&blob) + .is_err() + ); + // An unknown submapper is wired as submapper 0. + let m = board(ExtBoard::M197 { sub: 9 }, 32, 512); + assert_eq!(m.board, ExtBoard::M197 { sub: 0 }); +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 421538f0..85bb63bd 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -144,7 +144,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // duplicate of 114 (the 114 page) and shares its model. A separate arm // so each release's batch keeps its own rationale beside its ids. #[allow(clippy::match_same_arms)] - 114 | 182 | 215 => Some(MapperTier::Curated), + 114 | 182 | 197 | 215 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -254,7 +254,7 @@ mod tests { // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, // v3.2.0 (`mmc3_boards_ext.rs`). - 114, 182, 215, + 114, 182, 197, 215, ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index a7a000e7..2aac37d8 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1477,3 +1477,13 @@ fn extended_m215_super_808() { "extended_m215_super_808", ); } + +/// Submapper 0 wiring (the page's PRG CRC32 `0xC333F621` copy). +#[test] +fn extended_m197_super_fighter_iii() { + check( + "mapper-197-SuperFighter3/Super Fighter III (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m197_super_fighter_iii", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m197_super_fighter_iii.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m197_super_fighter_iii.snap new file mode 100644 index 00000000..33112610 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m197_super_fighter_iii.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-197-SuperFighter3/Super Fighter III (Unl) [!].zip +rom_sha256=1e52f7cbcbd9f54f35e59729f2a3ffa3c9c192b5263cb6272f30d552f36a3452 +frames=600 +fb_bytes=245760 +cycles=17838520 +audio_samples=439524 +audio_fnv1a64=97f7f4570c1607a0 +checkpoint f600 fb_fnv1a64=635fdbbf69da7e75 diff --git a/docs/compatibility.md b/docs/compatibility.md index 30dce72c..68c9d360 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -376,5 +376,6 @@ screenshot of that frame. The dumps themselves stay in the gitignored | 114 | Aladdin (SuperGame) (Mapper 114) [!] | `5489244538c94653f766bd80f07b2aa684ad139bddea68f40b60a0473c9286e2` | 600 | `screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png` | | 114.1 | Boogerman (Sugar Softec) (Unl) [!] (GoodNES labels it 215; staged with a NES 2.0 114.1 header) | `ba2d65f240d8f97c7b77973042d8141cf15116ffc3d73caed450c21e08fd54a2` | 600 | `screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png` | | 182 (as 114.0) | Super Donkey Kong (Unl) [!] | `67d06173c4b8239d46c84335591216b1adfa38344107405ec88c58327217a361` | 600 | `screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png` | +| 197 | Super Fighter III (Unl) [!] (submapper 0, PRG CRC32 `C333F621`) | `dfb4771a36dbcb05a2753a95810a11164665282ccac4cf7ee52d26d2ed6e5e4f` | 600 | `screenshots/external/mapper-197-SuperFighter3/Super Fighter III (Unl) [!].png` | | 215 | Mortal Kombat 3 Extra 60 (Unl) [!] (scramble pattern 4) | `19ccf8d3a3fc37b82d403d0668b00f05125bfcf01ad510dfff73e3ac30dad827` | 600 | `screenshots/external/mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].png` | | 215 | `2-in-1 Super 808 (Unl) [p1][!]` (scramble pattern 1, menu) | `4211468e61181f0956f10259ba6091ea3fead297e61ba7f22d7daa227463414a` | 600 | `screenshots/external/mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index f77e392d..334e2562 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,7 @@ boot snapshot in `external_extended.rs` and a committed screenshot. |------|-----|--------------|------|-----|-------| | 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. GoodNES labels *Boogerman* (Sugar Softec) mapper 215, the assignment the 215 page says was withdrawn; it never writes `$5007` and boots only as 114.1, so its D23 record uses a copy with a NES 2.0 114.1 header (`Nes::rom_sha256` excludes the header, so the identity is the dump's). | | 182 | — | (`INES_Mapper_182` → `INES_Mapper_114`) | Curated | as 114 | A duplicate of 114: the 182 page is a redirect, and the 114 page says Hosenkan's 182 games write 0 to `$6000`/`$6001` once and never again. Dispatched as 114 submapper 0; the full 114 model is kept rather than the "no `$600x`" simplification, since the zero writes leave it in the MMC3's own banking. | +| 197 | 0-3 | MMC3 with 2 / 4 KiB CHR banks (`INES_Mapper_197`) | Curated | MMC3 | Modelled as the page's WIRING, not its bank table: the MMC3 sees PA10 = PPU A11, PA11 = GND (sub 0, 3), Vcc (sub 1) or PPU A11 (sub 2), PA12 = PPU A12; its CHR outputs A10-A17 drive ROM A11-A18 and PPU A10 drives ROM A10. The page's tables for submappers 0-2 follow (unit-tested row by row), and `$8000` bit 7, which the page does not mention, still swaps the pattern tables as the MMC3 would. Submapper 3's `$6000-$7FFF` register (`S..P`: PRG A17 = P when S is set) is on the WRAM interface, so it is taken only while `$A001` allows a write. PRG, mirroring and IRQ are mapper 4's (Sharp). iNES 1.0 images are wired as submapper 0; the page's submapper 1 / 2 games are identified by PRG CRC32 there, which belongs in the per-game database, and no dump of either is staged. | | 215 | 0/1 | Sugar Softec UNL-8237 / UNL-8237A (`INES_Mapper_215`) | Curated | MMC3 (Sharp; the page names no revision) | `$5007` (mask `$F007`) selects one of eight scramble patterns (0, 2, 5, 6, 7 are the identity; 1, 3, 4 per the page's tables). `$5000` `MCS. BBBb` is 114's NROM override plus `C`, the 128 KiB mode that replaces PRG A17 / CHR A17 with `$5001` bits 4 / 5; `C` is applied after the NROM override, since the page describes it as a substitution on the A17 line both drive (the order is not stated). `$5001` is the outer bank (sub 0: PRG A18-A19 = bits 0-1, CHR A18-A19 = bits 2-3; sub 1: PRG A18/A19/A20 = bits 0/1/3, CHR A18-A20 = bits 1-3), power-up `$xF` (taken as `$0F`) and restored on a console reset; `$5000` and `$5007` power up `$00`. No WRAM unless a NES 2.0 header declares it. The page's index tables were checked black-box: pattern 1 (the *Super 808* menu, then *Aladdin* selected from it) and pattern 4 (*Mortal Kombat 3 Extra 60*) both run, and pattern 1's index table is the inverse of 114 submapper 0's, as printed. The "alternative outer bank registers in the `$6xxx` range" some multicarts carry are not modelled. | ### Mapper accuracy tiering (v1.2.0) diff --git a/screenshots/external/mapper-197-SuperFighter3/Super Fighter III (Unl) [!].png b/screenshots/external/mapper-197-SuperFighter3/Super Fighter III (Unl) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..210dd65e479bbf2cfe20c6a3921e05bfd76d847c GIT binary patch literal 5207 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 Date: Thu, 8 Oct 2026 20:52:11 -0400 Subject: [PATCH 012/104] feat(mappers): add mapper 157 (Bandai Datach) with its barcode reader Mapper 157 is the Datach Joint ROM System: an LZ93D50 game cartridge in the Datach main unit, which adds a barcode scanner and a 256-byte 24C02. Written from nesdev_wiki/INES_Mapper_157.xhtml and the pages it links (Bandai_FCG_board, Bandai_Datach_pinout, INES_Mapper_016). No other emulator's source was read; the page's link to FCEUX's barcode code was not followed. The board (m016_bandai_fcg.rs, FcgVariant::Datach, BandaiFcg::new_datach) - Registers mask $800F, $8000-$FFFF only. $8008 PRG bank, $8009 mirroring, $800A-$800C the latched 16-bit M2 IRQ: as 16.5. - 8 KiB unbanked CHR-RAM, no CHR banking (same path as 153). - $8000-$8003 bit 3 is the external X24C01's SCL (CHR A13 OUT); $8004-$8007 do nothing (PA12/PA13 grounded). $800D bit 5 is the internal 24C02's SCL, bit 6 both chips' SDA. - $6000-$7FFF read: bit 4 the two EEPROMs ANDed (open drain), bit 3 the scanner; other bits read 0, as on mapper 16 (the bus applies a partial driven mask only for $4020-$5FFF). - The X24C01 exists when NES 2.0 byte 10 declares PRG-NVRAM (Battle Rush). The 24C02 is the battery save; the X24C01 is in save states but not in the battery file (sram() is one slice; the page wants two files). The 24C02 random read. The file's EEPROM state machine (the region derived from Mesen2, attribution untouched) clears the word address on every START, so a random read (dummy address write, repeated START, read select) always began at $00. Ultraman Club reads its file table that way and, with the address cleared, reformatted the EEPROM and stopped on "no free block" at every boot; Dragon Ball Z and SD Gundam Wars listed garbage saves. An opt-in Eeprom::keeps_word_address, written from the protocol description and enabled only on mapper 157's 24C02, fixes all three. Mappers 16 and 159 are unchanged; whether their 24C02 games need the same is an open question (it would move shipped frames and the epoch). The reader (datach_barcode.rs, new) - Barcode::parse takes 13 or 8 digits (spaces/hyphens skipped) and returns a typed BarcodeError for empty, non-digit, wrong length or a wrong check digit (with the expected digit). - Barcode::modules draws the symbol from the published EAN tables (L, G, R sets, first-digit parity): 95 modules for EAN-13, 67 for EAN-8. - BarcodeReader presents it on bit 3 one module per 1,000 CPU cycles, between 128 light modules of card margin; light reads 1, a bar 0, and an idle slot 0. Advanced from notify_cpu_cycle: no wall clock. - None of polarity, idle level, module time or margin is documented; all four were measured black-box against the staged games: the opposite polarity drew no reaction from any game; idle-light left Battle Rush's title menu showing one item and ignoring A; accepted windows were 200-3,000 (DBZ, Gundam Wars) and 400-8,000 (J.League) cycles/module. - Mapper trait: has_barcode_reader / scan_barcode / barcode_scan_in_progress, default no-ops. Save state: the Bandai FCG blob stays version 2; mapper 157 appends a tail (external SCL, X24C01 bytes when present, the reader's modules, position and cycles left). No earlier 157 blob exists and no other variant's layout moved. No frame of any previously loadable game changes, so EMULATION_EPOCH and PPU_SNAPSHOT_VERSION are untouched. Tier: Curated (157 joins the v2.9.6 Curated arm and CURATED_IDS). Tests (red first: the 157 decode tests failed with UnsupportedMapper(157) before the board existed): 19 unit tests in total -- PRG/mirroring/IRQ/ CHR-RAM decode, the $6000 bits, both EEPROM buses incl. the random read, the wired AND, the exact bit-3 stream for EAN-8 96385074, mid-scan state round trip and refusal of a bad tail, every L/G/R pattern and parity row typed from the published tables, check-digit and malformed-input errors. Mutation (each restored from a copy): a digit-2 L-code entry, a parity row, the bit-3 polarity, the external SCL bit (3 -> 2), the keeps_word_address flag, and AND -> OR on the EEPROM outputs were all CAUGHT by the unit tests. The first L-code mutant initially survived (no worked example used digit 2); the full-table test was added for it. Not verified: any timing of the real scanner; the reader on Ultraman Club, Yu Yu Hakusho and Battle Rush (no card-reading screen was reached); the X24C01 path against Battle Rush's own transfer feature. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/datach_barcode.rs | 577 ++++++++++++++++++ crates/rustynes-mappers/src/lib.rs | 14 + .../rustynes-mappers/src/m016_bandai_fcg.rs | 551 ++++++++++++++++- crates/rustynes-mappers/src/mapper.rs | 21 + crates/rustynes-mappers/src/tier.rs | 11 +- 5 files changed, 1166 insertions(+), 8 deletions(-) create mode 100644 crates/rustynes-mappers/src/datach_barcode.rs diff --git a/crates/rustynes-mappers/src/datach_barcode.rs b/crates/rustynes-mappers/src/datach_barcode.rs new file mode 100644 index 00000000..f6795aa5 --- /dev/null +++ b/crates/rustynes-mappers/src/datach_barcode.rs @@ -0,0 +1,577 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! The Bandai Datach barcode reader: EAN / JAN symbol encoding and the +//! deterministic serial stream the Datach main unit presents to the CPU. +//! +//! # What the hardware does (`nesdev_wiki/INES_Mapper_157.xhtml`) +//! +//! The Datach main unit plugs into the Famicom cartridge port and carries a +//! one-dimensional barcode scanner. The page describes its interface in one +//! sentence: "the barcode scanner just returns a sequence of light and dark +//! periods from a one-dimesional barcode as the user moves it past the +//! sensor", read in **bit 3** of any CPU read of `$6000-$7FFF` (mask `$E000`; +//! bit 4 of the same read is the serial EEPROMs' data line). There is no +//! command register, no handshake and no buffering: the CPU samples one level +//! at a time and measures the widths of the runs itself. The page also says +//! "the game only expects UPCs to be scanned, so an emulator may wish to +//! provide an interface to convert a series of digits into a UPC". The cards +//! Bandai printed for the Datach carry JAN codes, which are EAN-13 / EAN-8 +//! symbols (the UPC family's 13- and 8-digit forms), so that is what this +//! module draws. +//! +//! # The symbol (public EAN / JAN specification) +//! +//! The digit patterns, guard bars and first-digit parity table below are the +//! published EAN-13 / EAN-8 symbology (GS1 General Specifications; the same +//! tables appear in every reference on the symbology). A symbol is a sequence +//! of equal-width **modules**, each either dark (a bar) or light (a space): +//! +//! - EAN-13: start guard `101`, six left-half digits (each 7 modules, drawn +//! from the L or G set as the first digit's parity pattern selects), centre +//! guard `01010`, six right-half digits (R set), end guard `101`: **95** +//! modules. The first digit has no bars of its own; it is encoded only in +//! the L/G parity pattern of digits 2-7. +//! - EAN-8: start guard, four L digits, centre guard, four R digits, end +//! guard: **67** modules. +//! +//! The last digit of either form is a check digit: weight the other digits +//! 3, 1, 3, 1, ... from the right, sum, and the check digit brings the sum to +//! a multiple of ten. [`Barcode::parse`] rejects a code whose check digit is +//! wrong, with the digit it expected, so a mistyped card number fails at the +//! boundary instead of inside the game. +//! +//! # The serial stream (this project's model; the page gives no timing) +//! +//! The page states neither the polarity of bit 3 nor any timing, and no other +//! public document found does either (the `NESdev` wiki and forum searched +//! 2026-10-08). The choices here were therefore settled **black-box, against +//! the games themselves**, by presenting candidate streams to the staged +//! Datach dumps at a screen waiting for a card and watching what the game +//! did. No other emulator's source was consulted (the page's FCEUX link was +//! deliberately not followed). The model and the measurements behind it: +//! +//! - **Polarity:** bit 3 reads **1 while the sensor sees light** (a space or +//! the card's white margin) and **0 over a dark bar** ([`DARK_LEVEL`]). +//! With the opposite polarity none of the three games that were brought to +//! a scan prompt reacted to any code at any speed. +//! - **Idle:** with no card passing, bit 3 reads the **dark** level, 0: an +//! empty slot reflects no light. Idle-light was tried first and *Battle +//! Rush* then showed only one of its three title-menu items and ignored the +//! A button; with idle-dark it shows all three and proceeds. +//! - **Timing:** every module lasts [`MODULE_CYCLES`] CPU cycles, and the +//! symbol is framed by [`QUIET_MODULES`] light modules before and after +//! (the card's white margin, which includes the symbology's quiet zones). +//! The window each game accepted, with these margins: *Dragon Ball Z: +//! Gekitou Tenkaichi Budoukai* (Karin's room) 200-3,000 cycles per module, +//! *SD Gundam Wars* (Barcode Lab) 200-3,000, *J.League Super Top Players* +//! (team select) 400-8,000; 5,000 and 8,000 failed in the first two and +//! 100 in all three. 1,000 sits inside all three windows, and a whole +//! EAN-13 swipe then takes (95 + 2 x 128) x 1,000 = 351,000 cycles, about +//! 0.2 s, a plausible hand swipe. Shorter margins narrowed the windows: +//! with 32 light modules *Dragon Ball Z* needed at least 700 cycles per +//! module. A real swipe is neither uniform nor exactly this speed; a +//! uniform, deterministic sweep is what a reproducible emulator can offer, +//! and the games measure run widths relative to one another. +//! +//! Time is counted in CPU cycles, never wall-clock time: the stream advances +//! from the mapper's per-M2 hook, so a scan is part of the deterministic +//! emulation (same scan at the same cycle, same frames) and its in-flight +//! position is part of the mapper's save state. + +use alloc::vec::Vec; +use core::fmt; + +/// Bit 3 of a `$6000-$7FFF` read while the sensor sees a **dark** module +/// (a bar) or no card at all (idle). A light module (a space or the card's +/// margin) reads the opposite level. +/// +/// Measured, not documented: see the module preamble. +pub const DARK_LEVEL: bool = false; + +/// CPU cycles each module of the symbol is presented for. +/// +/// Measured, not documented: see the module preamble and +/// `docs/mappers.md` (mapper 157) for the sweep that settled it. +pub const MODULE_CYCLES: u32 = 1000; + +/// Light modules presented before and after the symbol (the card's margin). +/// +/// The margin contains the symbology's quiet zones (at least 11 modules left +/// and 7 right of an EAN-13). Measured, not documented: see the module +/// preamble. +pub const QUIET_MODULES: u32 = 128; + +/// Why a barcode string was refused at the boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BarcodeError { + /// The string held no digits at all. + Empty, + /// A character other than an ASCII digit, at this character index + /// (spaces and hyphens, which cards print as separators, are accepted + /// and skipped). + NotADigit { + /// Character index in the input string. + index: usize, + /// The offending character. + found: char, + }, + /// Neither 13 (EAN-13 / JAN-13) nor 8 (EAN-8 / JAN-8) digits. + WrongLength(usize), + /// The last digit is not the check digit the others require. + BadCheckDigit { + /// The check digit the first 12 / 7 digits require. + expected: u8, + /// The check digit the string carried. + found: u8, + }, +} + +impl fmt::Display for BarcodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match *self { + Self::Empty => write!(f, "no digits"), + Self::NotADigit { index, found } => { + write!(f, "character {index} ('{found}') is not a digit") + } + Self::WrongLength(n) => write!(f, "{n} digits; a JAN/EAN code has 13 or 8"), + Self::BadCheckDigit { expected, found } => { + write!(f, "check digit is {found}, expected {expected}") + } + } + } +} + +/// A validated EAN-13 or EAN-8 (JAN) code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Barcode { + digits: [u8; 13], + len: u8, +} + +/// Left-half odd-parity ("L") digit patterns, most significant bit first, +/// 1 = dark module. +const L_CODES: [u8; 10] = [ + 0b000_1101, 0b001_1001, 0b001_0011, 0b011_1101, 0b010_0011, 0b011_0001, 0b010_1111, 0b011_1011, + 0b011_0111, 0b000_1011, +]; + +/// Right-half ("R") patterns: the bitwise complement of the L patterns. +const fn r_code(d: u8) -> u8 { + !L_CODES[d as usize] & 0x7F +} + +/// Left-half even-parity ("G") patterns: the R patterns mirrored. +const fn g_code(d: u8) -> u8 { + r_code(d).reverse_bits() >> 1 +} + +/// EAN-13 first-digit parity: for digits 2-7, bit 5 (digit 2) .. bit 0 +/// (digit 7), set where the digit uses the G set. +const FIRST_DIGIT_PARITY: [u8; 10] = [ + 0b00_0000, 0b00_1011, 0b00_1101, 0b00_1110, 0b01_0011, 0b01_1001, 0b01_1100, 0b01_0101, + 0b01_0110, 0b01_1010, +]; + +impl Barcode { + /// Parse and validate a 13- or 8-digit code. Spaces and hyphens are + /// skipped (cards print the digits in groups). + /// + /// # Errors + /// + /// [`BarcodeError`] for an empty string, a non-digit character, a digit + /// count other than 13 or 8, or a wrong check digit. + pub fn parse(text: &str) -> Result { + let mut digits = [0u8; 13]; + let mut len = 0usize; + for (index, c) in text.chars().enumerate() { + match c { + ' ' | '-' => {} + '0'..='9' => { + if len < digits.len() { + digits[len] = c as u8 - b'0'; + } + len += 1; + } + found => return Err(BarcodeError::NotADigit { index, found }), + } + } + match len { + 0 => return Err(BarcodeError::Empty), + 8 | 13 => {} + n => return Err(BarcodeError::WrongLength(n)), + } + let expected = check_digit(&digits[..len - 1]); + let found = digits[len - 1]; + if expected != found { + return Err(BarcodeError::BadCheckDigit { expected, found }); + } + // `len` is 8 or 13 here. + let len = u8::try_from(len).map_err(|_| BarcodeError::WrongLength(len))?; + Ok(Self { digits, len }) + } + + /// The digits, 13 or 8 of them. + #[must_use] + pub fn digits(&self) -> &[u8] { + &self.digits[..usize::from(self.len)] + } + + /// The symbol's modules, left to right, `true` = dark (a bar): 95 for + /// EAN-13, 67 for EAN-8. Quiet zones are not included. + #[must_use] + pub fn modules(&self) -> Vec { + let mut out = Vec::with_capacity(95); + let mut push = |pattern: u8, width: u32| { + for i in (0..width).rev() { + out.push(pattern >> i & 1 != 0); + } + }; + let d = self.digits(); + push(0b101, 3); + if d.len() == 13 { + let parity = FIRST_DIGIT_PARITY[usize::from(d[0])]; + for (i, &digit) in d[1..7].iter().enumerate() { + let g = parity >> (5 - i) & 1 != 0; + push( + if g { + g_code(digit) + } else { + L_CODES[usize::from(digit)] + }, + 7, + ); + } + push(0b01010, 5); + for &digit in &d[7..13] { + push(r_code(digit), 7); + } + } else { + for &digit in &d[..4] { + push(L_CODES[usize::from(digit)], 7); + } + push(0b01010, 5); + for &digit in &d[4..8] { + push(r_code(digit), 7); + } + } + push(0b101, 3); + out + } +} + +impl fmt::Display for Barcode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for &d in self.digits() { + write!(f, "{d}")?; + } + Ok(()) + } +} + +/// The EAN check digit for `body` (the digits before it): weights 3, 1, 3, +/// ... from the right. +fn check_digit(body: &[u8]) -> u8 { + let sum: u32 = body + .iter() + .rev() + .enumerate() + .map(|(i, &d)| u32::from(d) * if i % 2 == 0 { 3 } else { 1 }) + .sum(); + ((10 - sum % 10) % 10) as u8 +} + +/// The scanner's serial output as the CPU sees it, advanced one CPU cycle +/// at a time. +/// +/// Holds the modules of the scan in flight (quiet zones included), the +/// index of the module now under the sensor, and the cycles left on it. +/// Idle (`modules` empty) it reads the dark level. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct BarcodeReader { + /// Levels of the scan in flight, `true` = dark, quiet zones included. + modules: Vec, + /// Index into `modules` of the module under the sensor. + pos: u32, + /// CPU cycles left before `pos` advances. + remaining: u32, +} + +impl BarcodeReader { + /// Begin presenting `code`, replacing any scan in flight. The first + /// light quiet-zone module starts on the next CPU cycle. + pub fn scan(&mut self, code: &Barcode) { + let quiet = QUIET_MODULES as usize; + let mut modules = Vec::with_capacity(2 * quiet + 95); + modules.resize(quiet, false); + modules.extend(code.modules()); + modules.resize(modules.len() + quiet, false); + self.modules = modules; + self.pos = 0; + self.remaining = MODULE_CYCLES; + } + + /// Whether a scan is in flight. + #[must_use] + pub const fn is_scanning(&self) -> bool { + !self.modules.is_empty() + } + + /// Advance one CPU cycle. Ends the scan after the last quiet module. + pub fn tick(&mut self) { + if self.modules.is_empty() { + return; + } + self.remaining -= 1; + if self.remaining == 0 { + self.pos += 1; + if self.pos as usize >= self.modules.len() { + self.modules.clear(); + self.pos = 0; + } else { + self.remaining = MODULE_CYCLES; + } + } + } + + /// Bit 3 of a `$6000-$7FFF` read: [`DARK_LEVEL`] over a bar and when + /// idle, its complement over a space or the card's margin. + #[must_use] + pub fn level(&self) -> bool { + // Idle: no card in the slot, nothing reflects, the sensor sees dark. + let dark = self.modules.get(self.pos as usize).copied().unwrap_or(true); + if dark { DARK_LEVEL } else { !DARK_LEVEL } + } + + /// Append the reader's state to a mapper save-state blob: + /// `u16` module count, `u32` position, `u32` cycles left, then one byte + /// per eight modules (LSB first). + pub fn save(&self, out: &mut Vec) { + // At most 95 + 2 * QUIET_MODULES modules, far inside `u16`. + let count = u16::try_from(self.modules.len()).unwrap_or(u16::MAX); + out.extend_from_slice(&count.to_le_bytes()); + out.extend_from_slice(&self.pos.to_le_bytes()); + out.extend_from_slice(&self.remaining.to_le_bytes()); + for chunk in self.modules.chunks(8) { + let mut b = 0u8; + for (i, &m) in chunk.iter().enumerate() { + b |= u8::from(m) << i; + } + out.push(b); + } + } + + /// Restore from the front of `data` (the layout [`Self::save`] writes), + /// returning the bytes consumed, or `None` if `data` is too short or + /// describes an impossible position. + pub fn load(&mut self, data: &[u8]) -> Option { + let header = data.get(..10)?; + let count = usize::from(u16::from_le_bytes([header[0], header[1]])); + let pos = u32::from_le_bytes([header[2], header[3], header[4], header[5]]); + let remaining = u32::from_le_bytes([header[6], header[7], header[8], header[9]]); + let bytes = count.div_ceil(8); + let packed = data.get(10..10 + bytes)?; + if count > 0 && (pos as usize >= count || remaining == 0 || remaining > MODULE_CYCLES) { + return None; + } + self.modules = (0..count) + .map(|i| packed[i / 8] >> (i % 8) & 1 != 0) + .collect(); + self.pos = if count == 0 { 0 } else { pos }; + self.remaining = if count == 0 { 0 } else { remaining }; + Some(10 + bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::format; + use alloc::string::String; + + fn as_text(modules: &[bool]) -> String { + modules.iter().map(|&m| if m { '1' } else { '0' }).collect() + } + + #[test] + fn check_digit_is_validated_with_the_digit_it_expected() { + // 4006381333931: the symbology's customary worked example. + assert!(Barcode::parse("4006381333931").is_ok()); + assert_eq!( + Barcode::parse("4006381333932"), + Err(BarcodeError::BadCheckDigit { + expected: 1, + found: 2 + }) + ); + // EAN-8 96385074: weights 3,1,3,1,3,1,3 over 9638507 = 27+6+9+8+15+0+21 = 86. + assert!(Barcode::parse("96385074").is_ok()); + assert_eq!( + Barcode::parse("96385075"), + Err(BarcodeError::BadCheckDigit { + expected: 4, + found: 5 + }) + ); + } + + #[test] + fn malformed_strings_are_refused_with_a_typed_reason() { + assert_eq!(Barcode::parse(""), Err(BarcodeError::Empty)); + assert_eq!(Barcode::parse(" - "), Err(BarcodeError::Empty)); + assert_eq!( + Barcode::parse("40063813339x1"), + Err(BarcodeError::NotADigit { + index: 11, + found: 'x' + }) + ); + assert_eq!( + Barcode::parse("123456789012"), + Err(BarcodeError::WrongLength(12)) + ); + assert_eq!( + Barcode::parse("12345678901234"), + Err(BarcodeError::WrongLength(14)) + ); + // Grouping separators are skipped. + assert_eq!( + Barcode::parse("4 006381 333931").unwrap().digits(), + &[4, 0, 0, 6, 3, 8, 1, 3, 3, 3, 9, 3, 1] + ); + } + + /// The EAN-13 symbol for 4006381333931, written out by hand from the + /// published tables (first digit 4 selects parity LGLLGG for digits 2-7): + /// + /// ```text + /// 101 start + /// 0 L 0001101 0 G 0100111 6 L 0101111 + /// 3 L 0111101 8 G 0001001 1 G 0110011 + /// 01010 centre + /// 3 R 1000010 3 R 1000010 3 R 1000010 + /// 9 R 1110100 3 R 1000010 1 R 1100110 + /// 101 end + /// ``` + #[test] + fn ean13_modules_match_the_published_tables() { + let code = Barcode::parse("4006381333931").unwrap(); + let want = concat!( + "101", "0001101", "0100111", "0101111", "0111101", "0001001", "0110011", "01010", + "1000010", "1000010", "1000010", "1110100", "1000010", "1100110", "101", + ); + assert_eq!(as_text(&code.modules()), want); + assert_eq!(want.len(), 95); + } + + /// Every digit in every set, typed out from the published EAN tables + /// (L = odd parity left half, G = even parity left half, R = right half), + /// and the first-digit parity table. The worked examples below exercise + /// only some digits per set; this pins all thirty patterns and all ten + /// parity rows, so a corrupted table entry cannot hide behind a code + /// that happens not to use it. + #[test] + fn every_digit_pattern_and_parity_row_matches_the_published_tables() { + const L: [&str; 10] = [ + "0001101", "0011001", "0010011", "0111101", "0100011", "0110001", "0101111", "0111011", + "0110111", "0001011", + ]; + const G: [&str; 10] = [ + "0100111", "0110011", "0011011", "0100001", "0011101", "0111001", "0000101", "0010001", + "0001001", "0010111", + ]; + const R: [&str; 10] = [ + "1110010", "1100110", "1101100", "1000010", "1011100", "1001110", "1010000", "1000100", + "1001000", "1110100", + ]; + const PARITY: [&str; 10] = [ + "LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG", "LGGLLG", "LGGGLL", "LGLGLG", + "LGLGGL", "LGGLGL", + ]; + let bits = |p: u8| -> String { + (0..7) + .rev() + .map(|i| if p >> i & 1 != 0 { '1' } else { '0' }) + .collect() + }; + for d in 0..10u8 { + assert_eq!(bits(L_CODES[usize::from(d)]), L[usize::from(d)], "L {d}"); + assert_eq!(bits(g_code(d)), G[usize::from(d)], "G {d}"); + assert_eq!(bits(r_code(d)), R[usize::from(d)], "R {d}"); + let row: String = (0..6) + .map(|i| { + if FIRST_DIGIT_PARITY[usize::from(d)] >> (5 - i) & 1 != 0 { + 'G' + } else { + 'L' + } + }) + .collect(); + assert_eq!(row, PARITY[usize::from(d)], "parity row {d}"); + } + } + + /// EAN-8 96385074: start, L 9 6 3 8, centre, R 5 0 7 4, end. + #[test] + fn ean8_modules_match_the_published_tables() { + let code = Barcode::parse("96385074").unwrap(); + let want = concat!( + "101", "0001011", "0101111", "0111101", "0110111", "01010", "1001110", "1110010", + "1000100", "1011100", "101", + ); + assert_eq!(as_text(&code.modules()), want); + assert_eq!(want.len(), 67); + } + + /// The stream the CPU samples: quiet light, the symbol module by module, + /// quiet light, then idle -- each module exactly [`MODULE_CYCLES`] cycles, + /// read through [`DARK_LEVEL`]. + #[test] + fn the_reader_presents_each_module_for_module_cycles() { + let code = Barcode::parse("96385074").unwrap(); + let mut r = BarcodeReader::default(); + assert!(!r.is_scanning()); + assert_eq!(r.level(), DARK_LEVEL, "idle reads dark: an empty slot"); + r.scan(&code); + let mut seen = String::new(); + let mut cycles = 0u32; + while r.is_scanning() { + if cycles.is_multiple_of(MODULE_CYCLES) { + seen.push(if r.level() == DARK_LEVEL { '1' } else { '0' }); + } else { + // Within a module the level never changes. + assert_eq!(seen.ends_with('1'), r.level() == DARK_LEVEL); + } + r.tick(); + cycles += 1; + } + let quiet = "0".repeat(QUIET_MODULES as usize); + assert_eq!(seen, format!("{quiet}{}{quiet}", as_text(&code.modules()))); + assert_eq!(cycles, (67 + 2 * QUIET_MODULES) * MODULE_CYCLES); + assert_eq!(r.level(), DARK_LEVEL, "idle (dark) again after the scan"); + } + + #[test] + fn reader_state_round_trips_mid_scan_and_refuses_garbage() { + let mut r = BarcodeReader::default(); + r.scan(&Barcode::parse("4006381333931").unwrap()); + for _ in 0..(40 * MODULE_CYCLES + 17) { + r.tick(); + } + let mut blob = Vec::new(); + r.save(&mut blob); + let mut back = BarcodeReader::default(); + assert_eq!(back.load(&blob), Some(blob.len())); + assert_eq!(back, r); + // A position past the end is refused. + let mut bad = blob.clone(); + bad[2..6].copy_from_slice(&10_000u32.to_le_bytes()); + assert_eq!(BarcodeReader::default().load(&bad), None); + assert_eq!(BarcodeReader::default().load(&blob[..5]), None); + // An idle reader round-trips to idle. + let mut idle = Vec::new(); + BarcodeReader::default().save(&mut idle); + assert_eq!(idle.len(), 10); + let mut back = r.clone(); + assert_eq!(back.load(&idle), Some(10)); + assert!(!back.is_scanning()); + } +} diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index eefad19d..3bf2f921 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -46,6 +46,7 @@ use alloc::{boxed::Box, string::ToString}; mod a12_filter; mod bmc_simple; mod cartridge; +mod datach_barcode; mod fds; mod header; mod homebrew_boards; @@ -150,6 +151,10 @@ mod waixing; pub use bmc_simple::{new_m164, new_m261, new_m286, new_m289, new_m320, new_m336, new_m349}; pub use cartridge::{Cartridge, ConsoleType, Mirroring, Region, RomError, VsPpuPalette, VsPpuType}; +pub use datach_barcode::{ + Barcode, BarcodeError, BarcodeReader, DARK_LEVEL as BARCODE_DARK_LEVEL, + MODULE_CYCLES as BARCODE_MODULE_CYCLES, QUIET_MODULES as BARCODE_QUIET_MODULES, +}; pub use fds::{ DISK_BYTE_CYCLES, FDS_SIDE_LEN, Fds, FdsDisk, FdsQuirk, FdsTraceRec, HEAD_RESEEK_CYCLES, fds_crc32, parse_fds, quirk_for_crc, @@ -673,6 +678,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { .map_err(|e| RomError::InvalidConfig(e.to_string()))?; Box::new(fcg) } + 157 => { + // v3.2.0: the Datach Joint ROM System (`INES_Mapper_157`). The + // NES 2.0 PRG-NVRAM field declares only the game cartridge's own + // X24C01 (byte 10 `$10`, *Battle Rush*); the main unit's 24C02 is + // always present and not counted there. + let fcg = BandaiFcg::new_datach(prg_rom, chr_rom, h.mirroring, h.prg_nvram_size > 0) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?; + Box::new(fcg) + } 159 => { // Bandai LZ93D50 with a 128-byte X24C01 serial EEPROM. let fcg = BandaiFcg::new(prg_rom, chr_rom, h.mirroring, FcgVariant::Lz93d50_24c01) diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs index 1533717a..14541eae 100644 --- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs +++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs @@ -2,7 +2,7 @@ // // Provenance: the Bandai FCG serial-EEPROM handling (`Eeprom24C01` / `Eeprom24C02`) is derived from Mesen2 (GPL-3.0-or-later), `Core/NES/Mappers/Bandai/`. See docs/originality-and-provenance.md (Section 1) // and NOTICE for the complete, audited derivation record. -//! Bandai FCG (iNES mappers 16 and 159) implementation. +//! Bandai FCG (iNES mappers 16, 153, 157 and 159) implementation. //! //! Covers the Bandai FCG-1/FCG-2 and LZ93D50 ASICs. Banking: a 16 KiB //! switchable PRG bank at `$8000-$BFFF` (last bank fixed at `$C000`), eight @@ -33,6 +33,19 @@ //! freezes when it boots with zero-filled WRAM and runs after a soft reset; //! that is the game, not the board. //! +//! Mapper 157 (v3.2.0, written from `nesdev_wiki/INES_Mapper_157.xhtml`) is +//! the Datach Joint ROM System: an LZ93D50 game cartridge plugged into the +//! Datach main unit. Banking, mirroring and the IRQ are mapper 16 submapper +//! 5's; CHR is 8 KiB of unbanked CHR-RAM. The main unit holds a 256-byte +//! 24C02 (clocked by `$800D` bit 5) and the barcode reader; *Battle Rush*'s +//! cartridge adds a 128-byte X24C01 clocked by `$8000-$8003` bit 3 (CHR A13 +//! OUT; `$8004-$8007` do nothing). Both chips share SDA (`$800D` bit 6) and +//! their outputs are ANDed onto bit 4 of a `$6000-$7FFF` read; bit 3 of the +//! same read is the scanner ([`BarcodeReader`], `datach_barcode.rs`). The variant's code +//! lives outside the derived EEPROM region below, except for the +//! `keeps_word_address` flag on [`Eeprom`], written from the 24C02's +//! random-read protocol (see the field) and switched on only here. +//! //! ## Offsets (relative to the window base, masked to `$x..F`) //! //! | Offset | Function | @@ -74,6 +87,7 @@ )] use crate::cartridge::Mirroring; +use crate::datach_barcode::{Barcode, BarcodeReader}; use crate::mapper::{Mapper, MapperCaps, MapperError}; use alloc::{boxed::Box, vec::Vec}; use alloc::{format, vec}; @@ -108,6 +122,11 @@ pub enum FcgVariant { /// Mapper 153: LZ93D50 with 8 KiB of battery-backed WRAM and an outer /// 256 KiB PRG bank in place of the CHR registers. Lz93d50Wram, + /// Mapper 157: the Datach Joint ROM System. LZ93D50, unbanked CHR-RAM, + /// the main unit's 256-byte 24C02, an optional 128-byte X24C01 on the + /// game cartridge, and the barcode reader. Built by + /// [`BandaiFcg::new_datach`]. + Datach, } impl FcgVariant { @@ -117,7 +136,11 @@ impl FcgVariant { const fn responds_high(self) -> bool { matches!( self, - Self::Both | Self::Lz93d50_24c02 | Self::Lz93d50_24c01 | Self::Lz93d50Wram + Self::Both + | Self::Lz93d50_24c02 + | Self::Lz93d50_24c01 + | Self::Lz93d50Wram + | Self::Datach ) } /// LZ93D50 latches the IRQ counter (`$x0B/$x0C` write a latch); FCG-1/2 @@ -128,7 +151,7 @@ impl FcgVariant { const fn eeprom_bytes(self) -> usize { match self { Self::Lz93d50_24c01 => 128, - Self::Both | Self::Lz93d50_24c02 => 256, + Self::Both | Self::Lz93d50_24c02 | Self::Datach => 256, Self::Fcg | Self::Lz93d50Wram => 0, } } @@ -180,6 +203,19 @@ struct Eeprom { counter: u8, /// The bit currently driven back onto SDA toward the CPU (high = 1). output: u8, + /// v3.2.0, mapper 157 only: a START keeps the word address instead of + /// clearing it. A 24C02 "random read" is a dummy write of the word address + /// followed by a repeated START and a read-mode device select, and the + /// read then begins at that address: the chip's address counter is not + /// reset by a START. Clearing it made every random read start at `$00`. + /// *Ultraman Club - Supokon Fight!* reads its file table that way, and + /// with the address cleared it reformatted the main unit's EEPROM and + /// stopped on "no free block" at every boot; keeping it, the game reaches + /// its title (measured 2026-10-08). Written from that protocol + /// description, not from any emulator. Off for mappers 16 / 159, whose + /// shipped behaviour this change does not touch (see `docs/mappers.md`, + /// mapper 157, for the open question it raises about mapper 16). + keeps_word_address: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -207,6 +243,7 @@ impl Eeprom { data: 0, counter: 0, output: 1, + keeps_word_address: false, } } @@ -257,7 +294,9 @@ impl Eeprom { } else { I2cMode::ChipAddress }; - self.addr = 0; + if !self.keeps_word_address { + self.addr = 0; + } self.counter = 0; self.output = 1; } else if self.last_scl != 0 && scl != 0 && sda > self.last_sda { @@ -413,7 +452,7 @@ impl Eeprom { } } -/// Bandai FCG mapper (iNES mappers 16 + 159). +/// Bandai FCG mapper (iNES mappers 16, 153, 157 and 159). pub struct BandaiFcg { prg_rom: Box<[u8]>, chr: Box<[u8]>, @@ -442,6 +481,17 @@ pub struct BandaiFcg { wram_enabled: bool, /// Mapper 153: 8 KiB of battery-backed WRAM; empty on the other variants. wram: Box<[u8]>, + + /// Mapper 157: the game cartridge's own 128-byte X24C01, present when the + /// NES 2.0 header declares PRG-NVRAM (*Battle Rush*). It shares SDA with + /// the main unit's 24C02 but has its own clock, `$8000-$8003` bit 3. + ext_eeprom: Option, + /// Mapper 157: the last level written to the external EEPROM's SCL + /// (`$8000-$8003` bit 3). + ext_scl: bool, + /// Mapper 157: the barcode scanner's serial output (bit 3 of a + /// `$6000-$7FFF` read). Idle on every other variant. + barcode: BarcodeReader, } impl BandaiFcg { @@ -503,9 +553,83 @@ impl BandaiFcg { } else { Box::new([]) }, + ext_eeprom: None, + ext_scl: false, + barcode: BarcodeReader::default(), }) } + /// Construct mapper 157, the Datach Joint ROM System + /// (`nesdev_wiki/INES_Mapper_157.xhtml`). + /// + /// `external_x24c01` adds the game cartridge's 128-byte X24C01, which the + /// page says the NES 2.0 header signals with PRG-NVRAM (byte 10 = `$10`) + /// and which only *Battle Rush: Build up Robot Tournament* carries. + /// + /// # Errors + /// + /// As [`Self::new`]. + pub fn new_datach( + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + mirroring: Mirroring, + external_x24c01: bool, + ) -> Result { + let mut m = Self::new(prg_rom, chr_rom, mirroring, FcgVariant::Datach)?; + if let Some(ee) = m.eeprom.as_mut() { + ee.keeps_word_address = true; + } + if external_x24c01 { + m.ext_eeprom = Some(Eeprom::new(128, true)); + } + Ok(m) + } + + /// Mapper 157's save-state tail, after the fields every variant writes: + /// the external SCL level, the external X24C01's 128 bytes when the + /// cartridge has one (fixed by the header, so not flagged), then the + /// barcode reader ([`BarcodeReader::save`]). Added with the variant in + /// v3.2.0; no earlier blob of this variant exists, so + /// [`SAVE_STATE_VERSION`] stays 2 and the other variants' layouts are + /// unchanged. + fn save_datach_tail(&self, out: &mut Vec) { + out.push(u8::from(self.ext_scl)); + if let Some(ext) = self.ext_eeprom.as_ref() { + out.extend_from_slice(&ext.mem); + } + self.barcode.save(out); + } + + /// Restore [`Self::save_datach_tail`]. Validates the whole tail before + /// changing anything. + fn load_datach_tail(&mut self, tail: &[u8]) -> Result<(), MapperError> { + let ext_len = self.ext_eeprom.as_ref().map_or(0, |e| e.mem.len()); + let fixed = 1 + ext_len; + let short = || MapperError::Invalid(format!("Datach state tail of {} bytes", tail.len())); + if tail.len() < fixed { + return Err(short()); + } + let mut reader = BarcodeReader::default(); + let used = reader.load(&tail[fixed..]).ok_or_else(short)?; + if fixed + used != tail.len() { + return Err(short()); + } + self.ext_scl = tail[0] != 0; + if let Some(ext) = self.ext_eeprom.as_mut() { + ext.mem.copy_from_slice(&tail[1..fixed]); + } + self.barcode = reader; + Ok(()) + } + + /// The data line both EEPROMs drive back toward the CPU. "If there is + /// both an internal and an external EEPROM, the Data out from both + /// EEPROMs is ANDed (open drain circuit)" (`INES_Mapper_157`). + fn eeprom_data_out(&self) -> bool { + self.eeprom.as_ref().is_none_or(Eeprom::read_sda) + && self.ext_eeprom.as_ref().is_none_or(Eeprom::read_sda) + } + /// Mapper 153's outer bank as a 16 KiB bank offset (256 KiB = 16 banks). const fn outer_base(&self) -> usize { (self.outer as usize & 0x01) << 4 @@ -524,7 +648,9 @@ impl BandaiFcg { // the outer PRG bank instead, so `chr_banks` is never written there // and must not be consulted: routing through it mapped all eight // 1 KiB windows onto the first 1 KiB. - if self.variant == FcgVariant::Lz93d50Wram { + // Mapper 157 likewise: "8 KiB of unbanked CHR-RAM instead of + // bankswitched CHR-ROM", "No CHR banking is available". + if matches!(self.variant, FcgVariant::Lz93d50Wram | FcgVariant::Datach) { return addr as usize & 0x1FFF; } let slot = (addr as usize / CHR_BANK_1K) & 0x07; @@ -540,6 +666,19 @@ impl BandaiFcg { // PRG bank and `$4-$7` do nothing. 0x0..=0x3 if self.variant == FcgVariant::Lz93d50Wram => self.outer = value & 0x01, 0x4..=0x7 if self.variant == FcgVariant::Lz93d50Wram => {} + // Mapper 157: bit 3 of `$8000-$8003` is CHR A13 OUT, wired to + // the external X24C01's SCL ("External EEPROM Clock"). The ASIC's + // PA12/PA13 inputs are grounded, so only `$0-$3` respond and + // `$4-$7` do nothing; no CHR bank is selected by any of them. The + // SDA the external chip sees is the shared line, `$800D` bit 6. + 0x0..=0x3 if self.variant == FcgVariant::Datach => { + self.ext_scl = value & 0x08 != 0; + let sda = self.eeprom_ctrl & 0x40 != 0; + if let Some(ext) = self.ext_eeprom.as_mut() { + ext.write_lines(self.ext_scl, sda); + } + } + 0x4..=0x7 if self.variant == FcgVariant::Datach => {} 0x0..=0x7 => self.chr_banks[(off & 0x07) as usize] = value, 0x8 => self.prg_bank = value & 0x0F, 0x9 => { @@ -585,6 +724,11 @@ impl BandaiFcg { let sda = (value & 0x40) != 0; ee.write_lines(scl, sda); } + // Mapper 157: bit 6 is "Both EEPROM's I2C SDA"; the external + // chip keeps the clock last written to `$8000-$8003`. + if let Some(ext) = self.ext_eeprom.as_mut() { + ext.write_lines(self.ext_scl, value & 0x40 != 0); + } } _ => {} } @@ -626,6 +770,14 @@ impl Mapper for BandaiFcg { 0 } } + // Mapper 157 (mask `$E000`): bit 4 "Data out from I2C EEPROMs" + // (the two chips ANDed), bit 3 "Serial data out from Barcode + // scanner". The other bits are open bus on the board; like the + // mapper-16 read below they read 0 here, because the bus consults + // a partial driven-bit mask only for `$4020-$5FFF`. + 0x6000..=0x7FFF if self.variant == FcgVariant::Datach => { + (u8::from(self.eeprom_data_out()) << 4) | (u8::from(self.barcode.level()) << 3) + } 0x6000..=0x7FFF => { // EEPROM read appears in bit 4 (LZ93D50). Otherwise open bus // (the bus's open-bus latch handles unmapped reads, but the @@ -710,6 +862,9 @@ impl Mapper for BandaiFcg { } fn notify_cpu_cycle(&mut self) { + // Mapper 157: the scanner's stream is timed in CPU cycles (no wall + // clock), so it advances here, before the IRQ's early return. + self.barcode.tick(); if !self.irq_enabled { return; } @@ -726,12 +881,29 @@ impl Mapper for BandaiFcg { self.irq_pending } + fn has_barcode_reader(&self) -> bool { + self.variant == FcgVariant::Datach + } + + fn scan_barcode(&mut self, code: &Barcode) -> bool { + if self.variant != FcgVariant::Datach { + return false; + } + self.barcode.scan(code); + true + } + + fn barcode_scan_in_progress(&self) -> bool { + self.barcode.is_scanning() + } + fn current_mirroring(&self) -> Mirroring { self.mirroring } fn debug_info(&self) -> crate::mapper::MapperDebugInfo { let id = match self.variant { + FcgVariant::Datach => 157, FcgVariant::Lz93d50_24c01 => 159, FcgVariant::Lz93d50Wram => 153, _ => 16, @@ -791,6 +963,9 @@ impl Mapper for BandaiFcg { out.push(self.outer); out.push(u8::from(self.wram_enabled)); out.extend_from_slice(&self.wram); + if self.variant == FcgVariant::Datach { + self.save_datach_tail(&mut out); + } out } @@ -805,12 +980,29 @@ impl Mapper for BandaiFcg { Some(&v) => return Err(MapperError::UnsupportedVersion(v)), None => core_len, }; + // Mapper 157 appends a tail whose length depends on the scan in + // flight, so it is checked (and restored) separately, after the + // fixed part. + let (data, datach_tail) = if self.variant == FcgVariant::Datach { + if data.len() < expected { + return Err(MapperError::WrongLength { + expected, + got: data.len(), + }); + } + data.split_at(expected) + } else { + (data, &[][..]) + }; if data.len() != expected { return Err(MapperError::WrongLength { expected, got: data.len(), }); } + if self.variant == FcgVariant::Datach { + self.load_datach_tail(datach_tail)?; + } self.prg_bank = data[1]; self.chr_banks.copy_from_slice(&data[2..10]); self.mirroring = match data[10] { @@ -1306,4 +1498,351 @@ mod tests { Err(MapperError::UnsupportedVersion(1)) )); } + + // ---- Mapper 157, the Datach Joint ROM System (`INES_Mapper_157`) ------ + + /// A NES 2.0 image for mapper 157: `banks_16k` PRG banks numbered in + /// their first byte, no CHR-ROM (8 KiB CHR-RAM), and NES 2.0 byte 10 set + /// to `0x10` when the game cartridge carries the external X24C01 (the + /// page's *Battle Rush* row) or `0x00` when it does not. + fn m157_rom(banks_16k: usize, external_eeprom: bool) -> Vec { + let mut rom = vec![0u8; 16]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = banks_16k as u8; + rom[5] = 0; + rom[6] = 0xD0; // mapper low nibble $D (157 = $9D) + rom[7] = 0x98; // mapper high nibble $9, NES 2.0 identifier + rom[10] = if external_eeprom { 0x10 } else { 0x00 }; + rom[11] = 0x07; // 8 KiB CHR-RAM + rom.extend_from_slice(&synth_prg(banks_16k)); + rom + } + + fn m157(external_eeprom: bool) -> Box { + crate::parse(&m157_rom(16, external_eeprom)) + .expect("mapper 157 must construct") + .1 + } + + /// Banks: "CPU $8000-$BFFF: 16 KiB switchable PRG ROM bank", "CPU + /// $C000-$FFFF: 16 KiB PRG ROM bank, fixed to the last bank", and the + /// `$8008` register that "function[s] the same way as on INES Mapper 016, + /// submapper 5" (mask `$800F`, so `$FFF8` decodes as `$8008`). + #[test] + fn m157_prg_bank_at_8008_and_the_last_bank_fixed() { + let mut m = m157(false); + assert_eq!(m.cpu_read(0x8000), 0); + assert_eq!(m.cpu_read(0xC000), 15); + m.cpu_write(0x8008, 5); + assert_eq!(m.cpu_read(0x8000), 5); + assert_eq!(m.cpu_read(0xBFFF & 0xC000), 5); + assert_eq!(m.cpu_read(0xC000), 15); + m.cpu_write(0xFFF8, 9); + assert_eq!(m.cpu_read(0x8000), 9, "mask $800F: $FFF8 is $8008"); + // The LZ93D50's register window is $8000-$FFFF only. + m.cpu_write(0x6008, 2); + assert_eq!(m.cpu_read(0x8000), 9, "$6008 is not a register here"); + } + + /// "PPU $0000-$1FFF: 8 KiB unbanked CHR-RAM" and "No CHR banking is + /// available": writes to `$8000-$8007` must not move any CHR window, and + /// the eight 1 KiB windows must not alias. + #[test] + fn m157_chr_is_8k_of_unbanked_ram() { + let mut m = m157(false); + for a in 0x8000..=0x8007u16 { + m.cpu_write(a, 0x03); + } + for slot in 0..8u16 { + m.ppu_write(slot * 0x400 + 0x055, 0xB0 | slot as u8); + } + for slot in 0..8u16 { + assert_eq!(m.ppu_read(slot * 0x400 + 0x055), 0xB0 | slot as u8); + } + } + + /// `$8009`: "the same way as on INES Mapper 016" (0 V, 1 H, 2 1scA, 3 1scB). + #[test] + fn m157_mirroring_at_8009() { + let mut m = m157(false); + for (v, want) in [ + (0u8, Mirroring::Vertical), + (1, Mirroring::Horizontal), + (2, Mirroring::SingleScreenA), + (3, Mirroring::SingleScreenB), + ] { + m.cpu_write(0x8009, v); + assert_eq!(m.current_mirroring(), want, "$8009 = {v}"); + } + } + + /// `$800A-$800C`: the LZ93D50's latched 16-bit M2 down-counter. + #[test] + fn m157_irq_is_the_latched_lz93d50_counter() { + let mut m = m157(false); + m.cpu_write(0x800B, 0x02); + m.cpu_write(0x800C, 0x00); + m.cpu_write(0x800A, 0x01); + m.notify_cpu_cycle(); + m.notify_cpu_cycle(); + assert!(!m.irq_pending()); + m.notify_cpu_cycle(); + assert!(m.irq_pending(), "latch 2 reaches zero on the third M2"); + m.cpu_write(0x800A, 0x00); + assert!(!m.irq_pending(), "a $800A write acknowledges"); + } + + /// The main unit's 24C02 is the battery save, 256 bytes, with or without + /// the cartridge's X24C01. + #[test] + fn m157_battery_save_is_the_main_units_24c02() { + assert_eq!(m157(false).sram().len(), 256); + assert_eq!(m157(true).sram().len(), 256); + } + + /// `$6000-$7FFF` read: bit 4 is the EEPROMs' data out (released = high + /// when idle), bit 3 the barcode scanner, the rest open bus. + #[test] + fn m157_6000_drives_bits_4_and_3() { + let mut m = m157(true); + assert!(!m.cpu_read_unmapped(0x6000)); + let v = m.cpu_read(0x7FFF); + assert_eq!(v & 0x10, 0x10, "idle EEPROMs release SDA"); + assert_eq!(v & 0x08, 0, "an empty barcode slot reads dark (0)"); + assert_eq!(v & !0x18, 0, "undriven bits read 0 here"); + } + + fn datach(external: bool) -> BandaiFcg { + BandaiFcg::new_datach(synth_prg(16), Box::new([]), Mirroring::Vertical, external).unwrap() + } + + /// Drive the Datach's two I2C buses. The internal 24C02 is clocked by + /// `$800D` bit 5, the external X24C01 by `$8000-$8003` bit 3; both share + /// SDA, `$800D` bit 6 (`INES_Mapper_157`, "EEPROM Control" and "External + /// EEPROM Clock"). + struct DatachI2c<'a> { + m: &'a mut BandaiFcg, + external: bool, + } + + impl DatachI2c<'_> { + fn lines(&mut self, scl: bool, sda: bool) { + if self.external { + // Set SDA with the internal clock held low, then the external + // clock through CHR A13 OUT. + self.m.cpu_write(0x800D, u8::from(sda) << 6); + self.m.cpu_write(0x8000, u8::from(scl) << 3); + } else { + self.m + .cpu_write(0x800D, (u8::from(sda) << 6) | (u8::from(scl) << 5)); + } + } + fn start(&mut self) { + self.lines(false, true); + self.lines(true, true); + self.lines(true, false); + self.lines(false, false); + } + fn stop(&mut self) { + self.lines(false, false); + self.lines(true, false); + self.lines(true, true); + } + fn send_bit(&mut self, bit: bool) { + self.lines(false, bit); + self.lines(true, bit); + self.lines(false, bit); + } + /// Release SDA, clock once, and sample `$6000` bit 4 while SCL is high. + fn recv_bit(&mut self) -> bool { + self.lines(false, true); + self.lines(true, true); + let bit = self.m.cpu_read(0x6000) & 0x10 != 0; + self.lines(false, true); + bit + } + fn send_msb_first(&mut self, byte: u8) -> bool { + for i in (0..8).rev() { + self.send_bit(byte >> i & 1 != 0); + } + !self.recv_bit() + } + fn recv_msb_first(&mut self) -> u8 { + (0..8).fold(0u8, |acc, _| acc << 1 | u8::from(self.recv_bit())) + } + } + + /// The main unit's 24C02 at device address 000b ("the highest device + /// type bits being 1010b"): a byte write, then a **random read** (dummy + /// write of the word address, repeated START, read select), which must + /// begin at the address just written. Before the v3.2.0 + /// `keeps_word_address` change a repeated START cleared the address and + /// this read returned byte `$00`'s contents. + #[test] + fn m157_internal_24c02_random_read_starts_at_the_written_address() { + let mut m = datach(false); + { + let mut d = DatachI2c { + m: &mut m, + external: false, + }; + d.start(); + assert!(d.send_msb_first(0xA0), "device select acked"); + assert!(d.send_msb_first(0x37), "word address acked"); + assert!(d.send_msb_first(0xC4), "data acked"); + d.stop(); + d.start(); + assert!(d.send_msb_first(0xA0)); + assert!(d.send_msb_first(0x37)); + d.start(); // repeated START + assert!(d.send_msb_first(0xA1), "read select acked"); + assert_eq!(d.recv_msb_first(), 0xC4, "random read of $37"); + d.stop(); + } + assert_eq!(m.sram()[0x37], 0xC4); + assert_eq!(m.sram()[0x00], 0xFF, "byte $00 untouched"); + } + + /// The cartridge's X24C01 answers only on its own clock, `$8000-$8003` + /// bit 3, and (Bandai's convention, modelled by the existing X24C01 + /// state machine) shifts LSB-first. The 24C02 never sees that clock, so + /// its contents are untouched, and `$8004-$8007` clock nothing. + #[test] + fn m157_external_x24c01_is_clocked_by_8000_bit_3_only() { + let mut m = datach(true); + { + let mut d = DatachI2c { + m: &mut m, + external: true, + }; + d.start(); + // X24C01: 7 address bits LSB-first, then R/W = 0. + for i in 0..7 { + d.send_bit(0x15 >> i & 1 != 0); + } + d.send_bit(false); + assert!(!d.recv_bit(), "address acked"); + for i in 0..8 { + d.send_bit(0x6B >> i & 1 != 0); + } + assert!(!d.recv_bit(), "data acked"); + d.stop(); + } + assert_eq!(m.ext_eeprom.as_ref().unwrap().mem[0x15], 0x6B); + assert!(m.sram().iter().all(|&b| b == 0xFF), "24C02 untouched"); + // `$8004` is not the external clock: toggling it with SDA moving + // does not form a START on the X24C01. + m.cpu_write(0x8000, 0x00); // external SCL low + m.cpu_write(0x800D, 0x40); // SDA high + let before = m.ext_eeprom.as_ref().unwrap().mode; + m.cpu_write(0x8004, 0x08); // would raise SCL if it were decoded + m.cpu_write(0x800D, 0x00); // SDA falls: a START only if SCL were high + assert_eq!(m.ext_eeprom.as_ref().unwrap().mode, before); + assert!(!m.ext_scl); + // The same sequence through `$8003` is a START. + m.cpu_write(0x800D, 0x40); + m.cpu_write(0x8003, 0x08); + m.cpu_write(0x800D, 0x00); + assert_eq!(m.ext_eeprom.as_ref().unwrap().mode, I2cMode::Address); + } + + /// "the Data out from both EEPROMs is ANDed": either chip pulling SDA + /// low pulls bit 4 low. + #[test] + fn m157_eeprom_outputs_are_wired_and() { + let mut m = datach(true); + assert_eq!(m.cpu_read(0x6000) & 0x10, 0x10); + m.ext_eeprom.as_mut().unwrap().output = 0; + assert_eq!(m.cpu_read(0x6000) & 0x10, 0, "external low"); + m.ext_eeprom.as_mut().unwrap().output = 1; + m.eeprom.as_mut().unwrap().output = 0; + assert_eq!(m.cpu_read(0x6000) & 0x10, 0, "internal low"); + } + + /// The scanner's stream on bit 3: dark (0) idle, then the card's light + /// margin, the EAN-8 symbol 96385074 module by module (bar = 0, space = + /// 1), the light margin, and dark again. + #[test] + fn m157_scan_presents_the_symbol_on_bit_3() { + use crate::datach_barcode::{MODULE_CYCLES, QUIET_MODULES}; + let mut m = datach(false); + assert!(m.has_barcode_reader()); + let code = Barcode::parse("96385074").unwrap(); + assert!(m.scan_barcode(&code)); + let mut seen = alloc::string::String::new(); + let total = (67 + 2 * QUIET_MODULES) * MODULE_CYCLES; + for cycle in 0..total { + if cycle % MODULE_CYCLES == MODULE_CYCLES / 2 { + seen.push(if m.cpu_read(0x6000) & 0x08 != 0 { + '1' + } else { + '0' + }); + } + m.notify_cpu_cycle(); + } + let margin = "1".repeat(QUIET_MODULES as usize); + // The published EAN-8 modules for 96385074, inverted (bar = 0). + let symbol: alloc::string::String = concat!( + "101", "0001011", "0101111", "0111101", "0110111", "01010", "1001110", "1110010", + "1000100", "1011100", "101", + ) + .chars() + .map(|c| if c == '1' { '0' } else { '1' }) + .collect(); + assert_eq!(seen, alloc::format!("{margin}{symbol}{margin}")); + assert!(!m.barcode_scan_in_progress()); + assert_eq!(m.cpu_read(0x6000) & 0x08, 0, "dark again when done"); + } + + #[test] + fn only_the_datach_has_a_barcode_reader() { + let code = Barcode::parse("96385074").unwrap(); + for v in [ + FcgVariant::Both, + FcgVariant::Fcg, + FcgVariant::Lz93d50_24c02, + FcgVariant::Lz93d50_24c01, + ] { + let mut m = + BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, v).unwrap(); + assert!(!m.has_barcode_reader(), "{v:?}"); + assert!(!m.scan_barcode(&code), "{v:?}"); + } + } + + /// A save state taken mid-scan restores mid-scan: the remaining stream, + /// the external EEPROM and its clock all come back, and a truncated or + /// padded tail is refused. + #[test] + fn m157_state_round_trips_mid_scan() { + let mut a = datach(true); + a.cpu_write(0x8008, 3); + a.cpu_write(0x8001, 0x08); + a.ext_eeprom.as_mut().unwrap().mem[5] = 0x99; + a.eeprom.as_mut().unwrap().mem[6] = 0x77; + a.scan_barcode(&Barcode::parse("4006381333931").unwrap()); + for _ in 0..200_000 { + a.notify_cpu_cycle(); + } + let blob = a.save_state(); + let mut b = datach(true); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert!(b.barcode_scan_in_progress()); + assert!(b.ext_scl); + assert_eq!(b.ext_eeprom.as_ref().unwrap().mem[5], 0x99); + assert_eq!(b.sram()[6], 0x77); + for _ in 0..200_000 { + assert_eq!(a.cpu_read(0x6000), b.cpu_read(0x6000)); + a.notify_cpu_cycle(); + b.notify_cpu_cycle(); + } + assert!(b.load_state(&blob[..blob.len() - 1]).is_err()); + let mut long = blob.clone(); + long.push(0); + assert!(b.load_state(&long).is_err()); + // A board without the external chip has a shorter tail. + assert!(datach(false).load_state(&blob).is_err()); + } } diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index e71c6c48..d961d9b6 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -756,6 +756,27 @@ pub trait Mapper: Send { false } + /// Whether this cartridge has a barcode reader the player can scan + /// with. Only the Datach Joint ROM System (mapper 157) does. Default + /// `false`. Added in v3.2.0. + fn has_barcode_reader(&self) -> bool { + false + } + + /// Present `code` to the barcode reader, as if a card were swiped past + /// it, starting on the next CPU cycle and replacing any scan in flight. + /// Returns `false` (and does nothing) on a board without a reader. The + /// stream is timed in CPU cycles and saved with the mapper state; see + /// `datach_barcode.rs`. Added in v3.2.0. + fn scan_barcode(&mut self, _code: &crate::Barcode) -> bool { + false + } + + /// Whether a barcode scan is still being presented. Default `false`. + fn barcode_scan_in_progress(&self) -> bool { + false + } + /// Encode the mapper's mutable state into a tagged save-state blob. fn save_state(&self) -> Vec; diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index e46b5354..3cd24837 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -132,7 +132,13 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // GTROM (111), promoted from BestEffort once the board matched its // page (register window, bonus RAM, self-flashing) and a CC0 fixture // pinned it. None has a redistributable commercial ROM. - 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => { + // + // v3.2.0 adds 157, the Datach Joint ROM System, written from + // `INES_Mapper_157` (an LZ93D50 variant in m016) with the barcode + // reader in `datach_barcode.rs`. Curated on the D23 record: six + // staged dumps boot to their title screens and three accept a + // scanned code (`docs/compatibility.md`). + 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 157 | 163 | 192 | 195 | 228 | 249 => { Some(MapperTier::Curated) } @@ -242,7 +248,8 @@ mod tests { 178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". - 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, + 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, // v3.2.0. + 157, ]; #[test] From c20a855d7453c6c4105dd545f4e010a356e36818 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 21:11:08 -0400 Subject: [PATCH 013/104] feat(core): Nes::scan_barcode for the Datach, with boot and scan evidence Exposes the mapper-157 barcode reader through the core and records the D23 evidence for the six Datach titles. Core API (nes.rs, bus.rs; re-exported from lib.rs) - Nes::has_barcode_reader(): true only on mapper 157, so a frontend can show its scan control only for a Datach cart. - Nes::scan_barcode(&str) -> Result<(), BarcodeScanError>: the string is validated at the boundary (Barcode::parse: 13 or 8 digits, check digit included); BarcodeScanError::Malformed carries the typed BarcodeError, NoReader a cart without a reader. A scan replaces one in flight. - Nes::barcode_scan_in_progress(). - Barcode / BarcodeError re-exported from rustynes-core. - The doc states that a scan is NOT recorded by movies or carried by netplay and that callers running either must refuse it. Tests - rustynes-core: datach_barcode_scan_is_validated_and_survives_a_snapshot (NoReader on NROM, BadCheckDigit with the expected digit, a refused scan starts nothing, a whole-system snapshot taken mid-swipe restores mid-swipe and both runs end byte-identical). - external_extended: six 1200-frame boot baselines, and two scan tests that drive a game to a card-reading screen, then compare no scan, 4006381333931 (EAN-13) and 96385074 (EAN-8) from one restored state. Seen when pinned: Dragon Ball Z's Karin's room turns them into Dende and Android 18 (HP 96000 / BP 86750 / DP 12750); SD Gundam Wars' Barcode Lab into Rafflesia and the RX-78 Gundam. Each test asserts all three outcomes differ and snapshots their hashes. A missing dump skips. - external_coverage: six baselines (RUSTYNES_COVERAGE_FILTER=mapper-157, 6 passed), and their final frames committed under screenshots/external/mapper-157-Datach/ (looked at: Battle Rush's factory, DBZ's tournament desk, J.League's menu, Gundam Wars' file menu with four empty files, Ultraman Club's first event, Yu Yu Hakusho's tournament opening). Docs: docs/mappers.md gains the mapper-157 section (registers, the two EEPROMs and the battery-file gap, the 24C02 random-read fix and its open question for mapper 16, the reader's measured polarity / idle / timing with the windows each game accepted, save-state layout, movies and netplay, open bus, mutation record); docs/compatibility.md gains the "Curated evidence from local dumps (D23)" section with one row per title (SHA-256 after the header, 1141 frames, screenshot path). Mutation (game level, restored from copies): flipping the bit-3 polarity failed both scan tests ("the EAN-13 scan changed nothing"); turning keeps_word_address off failed five of six boot baselines and the DBZ scan test. Dumps: Hearto 1G1R NES 2.0 images, staged in the gitignored corpus, not committed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/bus.rs | 18 ++ crates/rustynes-core/src/lib.rs | 5 +- crates/rustynes-core/src/nes.rs | 99 ++++++++ .../tests/external_extended.rs | 222 ++++++++++++++++++ ..._Rush_Build_Up_Robot_Tournament_Japan.snap | 13 + ...ll_Z_Gekitou_Tenkaichi_Budoukai_Japan.snap | 13 + ...tach_J_League_Super_Top_Players_Japan.snap | 13 + ...ch_Datach_SD_Gundam_Gundam_Wars_Japan.snap | 13 + ...ach_Ultraman_Club_Supokon_Fight_Japan.snap | 13 + ...kusho_Bakutou_Ankoku_Bujutsukai_Japan.snap | 13 + ...l_extended__extended_m157_battle_rush.snap | 12 + ...7_dbz_karin_room_reads_a_scanned_card.snap | 7 + ...gon_ball_z_gekitou_tenkaichi_budoukai.snap | 12 + ...wars_barcode_lab_reads_a_scanned_card.snap | 7 + ...ended_m157_j_league_super_top_players.snap | 12 + ...__extended_m157_sd_gundam_gundam_wars.snap | 12 + ...nded_m157_ultraman_club_supokon_fight.snap | 12 + ..._yu_hakusho_bakutou_ankoku_bujutsukai.snap | 12 + docs/compatibility.md | 27 +++ docs/mappers.md | 93 +++++++- ...sh - Build Up Robot Tournament (Japan).png | Bin 0 -> 9211 bytes ...Z - Gekitou Tenkaichi Budoukai (Japan).png | Bin 0 -> 6441 bytes ...h - J.League Super Top Players (Japan).png | Bin 0 -> 3585 bytes ...tach - SD Gundam - Gundam Wars (Japan).png | Bin 0 -> 3435 bytes ...Ultraman Club - Supokon Fight! (Japan).png | Bin 0 -> 4599 bytes ...ho - Bakutou Ankoku Bujutsukai (Japan).png | Bin 0 -> 4627 bytes 26 files changed, 625 insertions(+), 3 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Battle_Rush_Build_Up_Robot_Tournament_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Dragon_Ball_Z_Gekitou_Tenkaichi_Budoukai_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_J_League_Super_Top_Players_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_SD_Gundam_Gundam_Wars_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Ultraman_Club_Supokon_Fight_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Yu_Yu_Hakusho_Bakutou_Ankoku_Bujutsukai_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_battle_rush.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dbz_karin_room_reads_a_scanned_card.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_j_league_super_top_players.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_sd_gundam_gundam_wars.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_ultraman_club_supokon_fight.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai.snap create mode 100644 screenshots/external/mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).png create mode 100644 screenshots/external/mapper-157-Datach/Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).png create mode 100644 screenshots/external/mapper-157-Datach/Datach - J.League Super Top Players (Japan).png create mode 100644 screenshots/external/mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).png create mode 100644 screenshots/external/mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).png create mode 100644 screenshots/external/mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).png diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 7fc22ddf..4800faef 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -2696,6 +2696,24 @@ impl SystemBus { self.mapper.set_disk_side(side); } + /// Whether the cartridge has a barcode reader (Datach, mapper 157). + #[must_use] + pub fn has_barcode_reader(&self) -> bool { + self.mapper.has_barcode_reader() + } + + /// Present a validated barcode to the cartridge's reader; `false` when + /// the cartridge has none. + pub fn scan_barcode(&mut self, code: &rustynes_mappers::Barcode) -> bool { + self.mapper.scan_barcode(code) + } + + /// Whether a barcode scan is still being presented. + #[must_use] + pub fn barcode_scan_in_progress(&self) -> bool { + self.mapper.barcode_scan_in_progress() + } + /// Number of selectable NSF songs (0 for cartridge / disk builds). #[must_use] pub fn nsf_song_count(&self) -> u8 { diff --git a/crates/rustynes-core/src/lib.rs b/crates/rustynes-core/src/lib.rs index 73073b81..a82f3cb6 100644 --- a/crates/rustynes-core/src/lib.rs +++ b/crates/rustynes-core/src/lib.rs @@ -98,14 +98,15 @@ pub use movie::{ #[cfg(feature = "debug-hooks")] pub use nes::TraceRec; pub use nes::{ - ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, MAX_CPU_OVERCLOCK, - MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam, + BarcodeScanError, ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, + MAX_CPU_OVERCLOCK, MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam, }; // v2.1.7 P5 — re-export the PPU-side hardware-revision knobs at the core surface // so downstream consumers (frontend, test-harness) depend on `rustynes-core`. pub use rewind::{ REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindError, RewindRing, }; +pub use rustynes_mappers::{Barcode, BarcodeError}; pub use rustynes_ppu::{PaletteInit, PpuRevision}; pub use save_state::{ BinReader, BinWriter, FORMAT_VERSION, HEADER_LEN, Header, MAGIC, MIN_FORMAT_VERSION, diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index c578e72a..3bbd1565 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -151,6 +151,18 @@ pub struct TraceRec { pub cycle: u64, } +/// Why [`Nes::scan_barcode`] refused a scan. +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum BarcodeScanError { + /// The string is not a valid 13- or 8-digit JAN / EAN code. + #[error("not a valid JAN/EAN barcode: {0}")] + Malformed(rustynes_mappers::BarcodeError), + /// The cartridge has no barcode reader (only the Datach, mapper 157, + /// does). + #[error("this cartridge has no barcode reader")] + NoReader, +} + /// The largest extra-scanline overclock the core accepts. /// /// [`Nes::set_extra_scanlines`] clamps to it, and a movie's options record @@ -1691,6 +1703,49 @@ impl Nes { self.bus.set_disk_side(side); } + // --- Datach barcode reader (mapper 157, v3.2.0) --- + + /// Whether the cartridge has a barcode reader. Only the Bandai Datach + /// Joint ROM System (mapper 157) does; a frontend shows its "scan a + /// barcode" control only when this is `true`. + #[must_use] + pub fn has_barcode_reader(&self) -> bool { + self.bus.has_barcode_reader() + } + + /// Swipe a barcode past the Datach reader. + /// + /// `code` is a JAN / EAN code of 13 or 8 digits (spaces and hyphens are + /// skipped), validated here at the boundary, check digit included. The + /// reader then presents the symbol's bars to the CPU over the following + /// CPU cycles, deterministically: the same scan at the same cycle yields + /// the same frames, and a save state taken mid-scan restores mid-scan. + /// A scan replaces any scan still in flight. + /// + /// A scan is an input that changes emulation, and it is **not** recorded + /// by movies or carried by netplay: callers running either must refuse + /// it rather than desynchronise (the desktop frontend does). + /// + /// # Errors + /// + /// [`BarcodeScanError::Malformed`] for a string that is not a valid + /// 13- or 8-digit code, and [`BarcodeScanError::NoReader`] when the + /// cartridge has no barcode reader. + pub fn scan_barcode(&mut self, code: &str) -> Result<(), BarcodeScanError> { + let code = rustynes_mappers::Barcode::parse(code).map_err(BarcodeScanError::Malformed)?; + if self.bus.scan_barcode(&code) { + Ok(()) + } else { + Err(BarcodeScanError::NoReader) + } + } + + /// Whether a barcode scan is still being presented to the CPU. + #[must_use] + pub fn barcode_scan_in_progress(&self) -> bool { + self.bus.barcode_scan_in_progress() + } + /// Start recording the diagnostic FDS read-stream trace (the `$4031` disk-byte /// stream + `$4025` control writes + side changes). Off by default and /// observation-only — it never affects emulation, so the determinism contract @@ -3488,6 +3543,50 @@ mod tests { bytes } + /// `Nes::scan_barcode` validates at the boundary, refuses a cartridge + /// with no reader, and a scan is part of the save state: a snapshot taken + /// mid-swipe restores mid-swipe and the two runs stay identical. + #[test] + fn datach_barcode_scan_is_validated_and_survives_a_snapshot() { + let mut nrom = Nes::from_rom(&synth_nrom(32, 8)).unwrap(); + assert!(!nrom.has_barcode_reader()); + assert_eq!( + nrom.scan_barcode("4006381333931"), + Err(BarcodeScanError::NoReader) + ); + + let rom = synth_board_rom(157, SweepHeader::Nes2 { submapper: 0 }, 32, 0); + let mut nes = Nes::from_rom(&rom).unwrap(); + assert!(nes.has_barcode_reader()); + assert_eq!( + nes.scan_barcode("4006381333932"), + Err(BarcodeScanError::Malformed( + rustynes_mappers::BarcodeError::BadCheckDigit { + expected: 1, + found: 2 + } + )) + ); + assert!( + !nes.barcode_scan_in_progress(), + "a refused scan starts nothing" + ); + assert_eq!(nes.scan_barcode("4006381333931"), Ok(())); + assert!(nes.barcode_scan_in_progress()); + nes.run_frame(); + assert!(nes.barcode_scan_in_progress(), "a swipe outlasts a frame"); + let mid = nes.snapshot(); + let mut other = Nes::from_rom(&rom).unwrap(); + other.restore(&mid).unwrap(); + assert!(other.barcode_scan_in_progress()); + for _ in 0..20 { + nes.run_frame(); + other.run_frame(); + } + assert!(!nes.barcode_scan_in_progress(), "the swipe ends"); + assert_eq!(nes.snapshot(), other.snapshot()); + } + /// Synthetic NES 2.0 NROM with console type Vs. System and a byte-13 Vs. /// PPU type (low nibble). fn synth_vs_nrom(vs_ppu_low_nibble: u8) -> Vec { diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 83b1bb3f..74d461f3 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1418,3 +1418,225 @@ fn extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu() { "extended_m253_dragon_ball_z_kyoushuu_saiya_jin_qi_long_zhu", ); } + +// ============================================================ +// Mapper 157 — Bandai Datach Joint ROM System (6 ROMs, v3.2.0) +// ============================================================ +// +// Boot baselines: 1200 idle frames (the Datach logo, then each game's own +// title or attract loop; `docs/compatibility.md`, D23). The barcode tests +// below drive a game to a screen waiting for a card, swipe a code past the +// reader through `Nes::scan_barcode`, and pin what the game did with it. + +const DATACH_DIR: &str = "mapper-157-Datach"; + +#[test] +fn extended_m157_battle_rush() { + check( + "mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).nes", + LONG_IDLE, + "extended_m157_battle_rush", + ); +} + +#[test] +fn extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai() { + check( + "mapper-157-Datach/Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).nes", + LONG_IDLE, + "extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai", + ); +} + +#[test] +fn extended_m157_j_league_super_top_players() { + check( + "mapper-157-Datach/Datach - J.League Super Top Players (Japan).nes", + LONG_IDLE, + "extended_m157_j_league_super_top_players", + ); +} + +#[test] +fn extended_m157_sd_gundam_gundam_wars() { + check( + "mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).nes", + LONG_IDLE, + "extended_m157_sd_gundam_gundam_wars", + ); +} + +#[test] +fn extended_m157_ultraman_club_supokon_fight() { + check( + "mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).nes", + LONG_IDLE, + "extended_m157_ultraman_club_supokon_fight", + ); +} + +#[test] +fn extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai() { + check( + "mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).nes", + LONG_IDLE, + "extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai", + ); +} + +/// One step of a Datach navigation script: run idle frames, or hold a button +/// for some frames and release it. +#[derive(Clone, Copy)] +enum DatachStep { + Idle(u32), + Hold(rustynes_core::Buttons, u32), +} + +/// Drive `rom` (under `mapper-157-Datach/`) through `steps`, then run +/// `after` frames three ways from the same state: with no scan, after +/// scanning `codes[0]`, and after scanning `codes[1]`. Returns the three +/// final-frame hashes, or `None` (a clean skip) when the dump is not staged. +fn datach_scan_outcomes( + rom: &str, + steps: &[DatachStep], + codes: [&str; 2], + after: u32, +) -> Option<[u64; 3]> { + use common::external::{Load, external_rom_path, load_nes}; + use rustynes_core::Buttons; + let rel = format!("{DATACH_DIR}/{rom}"); + if !external_rom_path(&rel).exists() { + eprintln!("[external_extended] SKIP {rel}: dump not staged"); + return None; + } + let Load::Ok(mut nes) = load_nes(&rel) else { + panic!("{rel} is a cartridge, not a disk"); + }; + assert!(nes.has_barcode_reader(), "mapper 157 has a reader"); + for step in steps { + match *step { + DatachStep::Idle(n) => (0..n).for_each(|_| { + nes.run_frame(); + }), + DatachStep::Hold(b, n) => { + nes.set_buttons(0, b); + (0..n).for_each(|_| { + nes.run_frame(); + }); + nes.set_buttons(0, Buttons::empty()); + } + } + } + let at_prompt = nes.snapshot(); + let mut out = [0u64; 3]; + for (i, code) in [None, Some(codes[0]), Some(codes[1])] + .into_iter() + .enumerate() + { + nes.restore(&at_prompt).expect("restore the prompt state"); + if let Some(code) = code { + nes.scan_barcode(code).expect("a valid code on a Datach"); + assert!(nes.barcode_scan_in_progress()); + } + for _ in 0..after { + nes.run_frame(); + } + assert!(!nes.barcode_scan_in_progress(), "the swipe has ended"); + let label = code.map_or_else(|| "noscan".to_string(), |c| format!("scan{c}")); + common::dump_frame_if_requested("datach", rom, &label, nes.framebuffer()); + out[i] = common::fnv1a64(nes.framebuffer()); + } + Some(out) +} + +/// *Dragon Ball Z: Gekitou Tenkaichi Budoukai*, Karin's room ("さあ おまえの +/// バーコードを みせてみろ!", "now show me your barcode!"). Seen when the +/// expectations were pinned: 4006381333931 brings out Dende ("HPを +/// かんぜんかいふく", full HP recovery), the EAN-8 96385074 Android 18 +/// (HP 96000 / BP 86750 / DP 12750), and with no scan the room keeps waiting. +#[test] +fn extended_m157_dbz_karin_room_reads_a_scanned_card() { + use DatachStep::{Hold, Idle}; + use rustynes_core::Buttons; + let steps = [ + Idle(300), + Hold(Buttons::START, 2), + Idle(300), + Hold(Buttons::START, 2), + Idle(60), + Hold(Buttons::START, 2), + Idle(120), + Hold(Buttons::DOWN, 2), + Idle(30), + Hold(Buttons::START, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(180), + Hold(Buttons::A, 2), + Idle(120), + ]; + let Some([none, ean13, ean8]) = datach_scan_outcomes( + "Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).nes", + &steps, + ["4006381333931", "96385074"], + 600, + ) else { + return; + }; + assert_ne!(none, ean13, "the EAN-13 scan changed nothing"); + assert_ne!(none, ean8, "the EAN-8 scan changed nothing"); + assert_ne!(ean13, ean8, "two different cards read the same"); + insta::assert_snapshot!( + "extended_m157_dbz_karin_room_reads_a_scanned_card", + format!( + "noscan fb={none:016x}\nscan 4006381333931 fb={ean13:016x}\nscan 96385074 fb={ean8:016x}" + ) + ); +} + +/// *SD Gundam Wars*, Barcode Lab ("バーコード インプット!"). Seen when the +/// expectations were pinned: 4006381333931 is analysed ("バーコード +/// カイセキチュウ") and becomes the unit "ラフレシア" (Rafflesia: mega +/// particle cannon, CP 10, 1 turn); 96385074 becomes the RX-78 Gundam (HP +/// 4030, AP 2720, DP 4420, CP 10). +#[test] +fn extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card() { + use DatachStep::{Hold, Idle}; + use rustynes_core::Buttons; + let steps = [ + Idle(900), + Hold(Buttons::START, 2), + Idle(120), + Hold(Buttons::DOWN, 2), + Idle(20), + Hold(Buttons::DOWN, 2), + Idle(20), + Hold(Buttons::A, 2), + Idle(150), + Hold(Buttons::A, 2), + Idle(300), + Hold(Buttons::A, 2), + Idle(100), + ]; + let Some([none, ean13, ean8]) = datach_scan_outcomes( + "Datach - SD Gundam - Gundam Wars (Japan).nes", + &steps, + ["4006381333931", "96385074"], + 720, + ) else { + return; + }; + assert_ne!(none, ean13, "the EAN-13 scan changed nothing"); + assert_ne!(none, ean8, "the EAN-8 scan changed nothing"); + assert_ne!(ean13, ean8, "two different cards read the same"); + insta::assert_snapshot!( + "extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card", + format!( + "noscan fb={none:016x}\nscan 4006381333931 fb={ean13:016x}\nscan 96385074 fb={ean8:016x}" + ) + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Battle_Rush_Build_Up_Robot_Tournament_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Battle_Rush_Build_Up_Robot_Tournament_Japan.snap new file mode 100644 index 00000000..7155b013 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Battle_Rush_Build_Up_Robot_Tournament_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).nes +rom_sha256=526faccfe5aefc87fc2d9e70c91c8f53573670ac8ed2b14700a4ea5f1354b829 +frames=1141 +fb_bytes=245760 +cycles=33949821 +audio_samples=836506 +audio_fnv1a64=bc99b7d845d5d50f +checkpoint f900 fb_fnv1a64=50a7dabaf8f99b08 +checkpoint f1100 fb_fnv1a64=32d65ea0969b3ec0 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Dragon_Ball_Z_Gekitou_Tenkaichi_Budoukai_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Dragon_Ball_Z_Gekitou_Tenkaichi_Budoukai_Japan.snap new file mode 100644 index 00000000..6b4c9811 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Dragon_Ball_Z_Gekitou_Tenkaichi_Budoukai_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).nes +rom_sha256=17fec54a27a7783c6e36739c00cc7ad1c9447d7788e6458486b169354e9a36fa +frames=1141 +fb_bytes=245760 +cycles=33949804 +audio_samples=836506 +audio_fnv1a64=d83eccd759aef6bd +checkpoint f900 fb_fnv1a64=d38f40ce1b7f0d45 +checkpoint f1100 fb_fnv1a64=d38f40ce1b7f0d45 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_J_League_Super_Top_Players_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_J_League_Super_Top_Players_Japan.snap new file mode 100644 index 00000000..854199c3 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_J_League_Super_Top_Players_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-157-Datach/Datach - J.League Super Top Players (Japan).nes +rom_sha256=608a3882b8066539a23185c05052fd3f166638be5466ef49911a493cafd8bc54 +frames=1141 +fb_bytes=245760 +cycles=33949794 +audio_samples=836505 +audio_fnv1a64=efb047572f0e710f +checkpoint f900 fb_fnv1a64=794595d57341dc74 +checkpoint f1100 fb_fnv1a64=794595d57341dc74 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_SD_Gundam_Gundam_Wars_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_SD_Gundam_Gundam_Wars_Japan.snap new file mode 100644 index 00000000..4d65ddb9 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_SD_Gundam_Gundam_Wars_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).nes +rom_sha256=db7b5863a551363245c0df770169e48180dcbd7cd908f8208c43b96b7c71e3ce +frames=1141 +fb_bytes=245760 +cycles=33949797 +audio_samples=836505 +audio_fnv1a64=0dc011cda1df5e06 +checkpoint f900 fb_fnv1a64=a38c3215be1d1a8d +checkpoint f1100 fb_fnv1a64=a38c3215be1d1a8d diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Ultraman_Club_Supokon_Fight_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Ultraman_Club_Supokon_Fight_Japan.snap new file mode 100644 index 00000000..3060dbe8 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Ultraman_Club_Supokon_Fight_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).nes +rom_sha256=3bfe0216c485534c10e9931389e0265e6f27382c2eed3b102cc53434b2920f1b +frames=1141 +fb_bytes=245760 +cycles=33949796 +audio_samples=836505 +audio_fnv1a64=23559da2e2a5df1a +checkpoint f900 fb_fnv1a64=1719dca5cef7a325 +checkpoint f1100 fb_fnv1a64=ebb8fc5e3128e8c6 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Yu_Yu_Hakusho_Bakutou_Ankoku_Bujutsukai_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Yu_Yu_Hakusho_Bakutou_Ankoku_Bujutsukai_Japan.snap new file mode 100644 index 00000000..157a13a8 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_157_Datach_Datach_Yu_Yu_Hakusho_Bakutou_Ankoku_Bujutsukai_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).nes +rom_sha256=e8286be877a54d1f15171e56a753c677b2c2478a78431ce3cd11613c5d26ebbe +frames=1141 +fb_bytes=245760 +cycles=33949801 +audio_samples=836505 +audio_fnv1a64=33f6fa2ebbd47d08 +checkpoint f900 fb_fnv1a64=1719dca5cef7a325 +checkpoint f1100 fb_fnv1a64=ad6a71fde482d482 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_battle_rush.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_battle_rush.snap new file mode 100644 index 00000000..287b00c3 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_battle_rush.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).nes +rom_sha256=526faccfe5aefc87fc2d9e70c91c8f53573670ac8ed2b14700a4ea5f1354b829 +frames=1200 +fb_bytes=245760 +cycles=35706868 +audio_samples=879800 +audio_fnv1a64=717ae46ccca67b45 +checkpoint f1200 fb_fnv1a64=25489f09552cf560 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dbz_karin_room_reads_a_scanned_card.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dbz_karin_room_reads_a_scanned_card.snap new file mode 100644 index 00000000..32cd771f --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dbz_karin_room_reads_a_scanned_card.snap @@ -0,0 +1,7 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: "format!(\"noscan fb={none:016x}\\nscan 4006381333931 fb={ean13:016x}\\nscan 96385074 fb={ean8:016x}\")" +--- +noscan fb=5c7373550455d5d3 +scan 4006381333931 fb=9e94d6aeba342f5b +scan 96385074 fb=bab40f1237650807 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai.snap new file mode 100644 index 00000000..745671a5 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_dragon_ball_z_gekitou_tenkaichi_budoukai.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).nes +rom_sha256=17fec54a27a7783c6e36739c00cc7ad1c9447d7788e6458486b169354e9a36fa +frames=1200 +fb_bytes=245760 +cycles=35706859 +audio_samples=879799 +audio_fnv1a64=1462781aeb76a5f1 +checkpoint f1200 fb_fnv1a64=4571059741c49ae8 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card.snap new file mode 100644 index 00000000..668f077f --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card.snap @@ -0,0 +1,7 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: "format!(\"noscan fb={none:016x}\\nscan 4006381333931 fb={ean13:016x}\\nscan 96385074 fb={ean8:016x}\")" +--- +noscan fb=c8f8ed2b15a261fe +scan 4006381333931 fb=07fefe1d257b6c08 +scan 96385074 fb=7c48c8d44e7b6f6a diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_j_league_super_top_players.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_j_league_super_top_players.snap new file mode 100644 index 00000000..ebff0a72 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_j_league_super_top_players.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-157-Datach/Datach - J.League Super Top Players (Japan).nes +rom_sha256=608a3882b8066539a23185c05052fd3f166638be5466ef49911a493cafd8bc54 +frames=1200 +fb_bytes=245760 +cycles=35706845 +audio_samples=879799 +audio_fnv1a64=98c04c3daeadefd0 +checkpoint f1200 fb_fnv1a64=752a680d23807455 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_sd_gundam_gundam_wars.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_sd_gundam_gundam_wars.snap new file mode 100644 index 00000000..6030e5a1 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_sd_gundam_gundam_wars.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).nes +rom_sha256=db7b5863a551363245c0df770169e48180dcbd7cd908f8208c43b96b7c71e3ce +frames=1200 +fb_bytes=245760 +cycles=35706845 +audio_samples=879799 +audio_fnv1a64=1679e81fe072ef30 +checkpoint f1200 fb_fnv1a64=e372d157e23feec0 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_ultraman_club_supokon_fight.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_ultraman_club_supokon_fight.snap new file mode 100644 index 00000000..78af6bf8 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_ultraman_club_supokon_fight.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).nes +rom_sha256=3bfe0216c485534c10e9931389e0265e6f27382c2eed3b102cc53434b2920f1b +frames=1200 +fb_bytes=245760 +cycles=35706848 +audio_samples=879799 +audio_fnv1a64=9ec2aca5d45d0c6f +checkpoint f1200 fb_fnv1a64=aade9eaf3da4a43a diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai.snap new file mode 100644 index 00000000..942e2162 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m157_yu_yu_hakusho_bakutou_ankoku_bujutsukai.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).nes +rom_sha256=e8286be877a54d1f15171e56a753c677b2c2478a78431ce3cd11613c5d26ebbe +frames=1200 +fb_bytes=245760 +cycles=35706843 +audio_samples=879799 +audio_fnv1a64=dffca4d48c823c1b +checkpoint f1200 fb_fnv1a64=899ef14e693462ee diff --git a/docs/compatibility.md b/docs/compatibility.md index 73137556..f736cbf1 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -362,3 +362,30 @@ FE-11, which the v3.1 → v4.0 line plan does not schedule: - **CRC32 vs. SHA-256 for ROM identification.** We use SHA-256 for save state directory naming (lower collision risk). For ROM compatibility databases, CRC32 is the community standard; we may add it as a secondary key. - **Region override.** Some users want to play PAL versions of NTSC games at NTSC speed. Plan: expose a region override in the settings UI; warn that this may break timing-sensitive ROMs. + +## Curated evidence from local dumps (D23) + +A local dump counts as Curated evidence when the record names the title, the +SHA-256 of the ROM bytes after the 16-byte header (`Nes::rom_sha256`), the +frames run, and a committed screenshot. The dumps themselves are never +committed; the boot baselines live in `external_extended.rs` and +`external_coverage.rs`. + +| Mapper | Title | SHA-256 after the header (`Nes::rom_sha256`) | Frames | Screenshot | +|---|---|---|---|---| +| 157 | Datach - Battle Rush - Build Up Robot Tournament (Japan) | `cbc404d57879beca0b65b855b19fda9e017e7d8cc716941b52bbea8f8a6ee859` | 1141 | `screenshots/external/mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).png` | +| 157 | Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan) | `a1370e7410a98a4a2de19f4872281a065b1d7d7cd7b5f2ba0da70504b4a1d52d` | 1141 | `screenshots/external/mapper-157-Datach/Datach - Dragon Ball Z - Gekitou Tenkaichi Budoukai (Japan).png` | +| 157 | Datach - J.League Super Top Players (Japan) | `a7b1e4693093c8a155747f9023399ebb2537064e788970ecdcccee467108b62e` | 1141 | `screenshots/external/mapper-157-Datach/Datach - J.League Super Top Players (Japan).png` | +| 157 | Datach - SD Gundam - Gundam Wars (Japan) | `a6a909d829e3d9ef1a14d002f8ac3c59173420530d467c09fe338b28143f4308` | 1141 | `screenshots/external/mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).png` | +| 157 | Datach - Ultraman Club - Supokon Fight! (Japan) | `85f0ab77ae56df26e18bf55caafb795ec24712c168912f0963756fe4daa2ef65` | 1141 | `screenshots/external/mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).png` | +| 157 | Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan) | `acc54496853ce6306589790857b0d56145d8382bd00f5d0f40ecdfa2906ad349` | 1141 | `screenshots/external/mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).png` | + +Mapper 157 notes: the six Hearto 1G1R NES 2.0 dumps (the GoodNES copies carry +a mapper-16 header). The screenshots are `external_coverage`'s final frame +(1141 frames of START taps): *Battle Rush*'s factory, *Dragon Ball Z*'s +tournament desk, *J.League*'s title menu, *SD Gundam Wars*' file menu (four +empty files), *Ultraman Club*'s first event and *Yu Yu Hakusho*'s tournament +opening. *Dragon Ball Z* and *SD Gundam Wars* also read scanned barcodes +(`extended_m157_dbz_karin_room_reads_a_scanned_card`, +`extended_m157_gundam_wars_barcode_lab_reads_a_scanned_card`; what each code +became is in `docs/mappers.md`, mapper 157). diff --git a/docs/mappers.md b/docs/mappers.md index a93c5a69..0eefa1e1 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -323,7 +323,7 @@ boot-smoke (no redistributable behavioral fixtures exist for these boards). | iNES | Submapper | Name | Audio | IRQ | Notes | |------|-----------|------|-------|-----|-------| -| 16 / 159 | 0,4,5 | Bandai FCG | — | CPU | DBZ, Famicom Jump II, Datach. +minimal I2C EEPROM (24C02/24C01). | +| 16 / 159 | 0,4,5 | Bandai FCG | — | CPU | DBZ, Famicom Jump II. +minimal I2C EEPROM (24C02/24C01). The Datach is mapper 157 (v3.2.0, below). | | 18 | — | Jaleco SS88006 | — (ADPCM decoded-not-emulated) | CPU | Goemon Gaiden, Doropie. Nibble-paired banking; selectable-width IRQ. | | 64 | — | Tengen RAMBO-1 | — | A12 + CPU | Klax, Skull & Crossbones. Dual-mode IRQ (reuses MMC3 A12 filter). The zero test follows a reload as well as a decrement, so a latch of 0 asserts on every clock (v2.9.8; *Skull & Crossbones* depends on it). The reload "+1 kick" and the IRQ delay are not modelled. | | 65 | — | Irem H3001 | — | CPU | Daiku no Gen-san, Spartan X 2. 16-bit reload-latch down-counter. | @@ -838,6 +838,97 @@ path), were all CAUGHT by the unit tests or `roster_boards.rs`. Two needed a new test first: the bus's floating-read call, which no mapper-level test can reach, and the PPU's unfolded `$3xxx` path. The record is in the v2.9.6 plan. +### Mapper 157 — Bandai Datach Joint ROM System (v3.2.0) + +Written from `nesdev_wiki/INES_Mapper_157.xhtml` and the pages it links +(`Bandai_FCG_board`, `Bandai_Datach_pinout`, `INES_Mapper_016`). No other +emulator's source was read; the page's link to FCEUX's barcode code was +deliberately not followed. Curated (D23, `docs/compatibility.md`). + +| iNES | Sub | Board (page) | Tier | IRQ | Notes | +|------|-----|--------------|------|-----|-------| +| 157 | — | Datach main unit + LZ93D50 game cartridge (`INES_Mapper_157`) | Curated | LZ93D50 (latched 16-bit M2) | A `FcgVariant::Datach` in `m016_bandai_fcg.rs`, built by `BandaiFcg::new_datach`, outside the file's derived EEPROM region except for one opt-in flag (below). Registers mask `$800F` in `$8000-$FFFF` only. `$8008` PRG bank at `$8000` (last bank fixed at `$C000`), `$8009` mirroring, `$800A-$800C` IRQ: as mapper 16 submapper 5. 8 KiB of unbanked CHR-RAM; no CHR banking. `$8000-$8003` bit 3 is the external X24C01's SCL (CHR A13 OUT); `$8004-$8007` do nothing (PA12/PA13 grounded). `$800D` bit 5 is the internal 24C02's SCL, bit 6 both chips' SDA. `$6000-$7FFF` reads: bit 4 the two EEPROMs' data out ANDed, bit 3 the barcode reader. | + +**The two EEPROMs.** The main unit's 256-byte 24C02 is always present and is +the battery save (`sram()`, 256 bytes). The game cartridge's 128-byte X24C01 +exists only when the NES 2.0 header declares PRG-NVRAM (byte 10 `$10`, *Battle +Rush*); it is reached through the existing X24C01 state machine, on its own +clock. It is held in save states but **not** in the battery file: the page +asks for each EEPROM in its own file, and `Mapper::sram` exposes one slice. +*Battle Rush*'s cartridge-side save is therefore lost between sessions (a +known gap). The main unit's EEPROM is likewise saved per game, not shared +between Datach games as the hardware shares it. + +**The 24C02 random read (`keeps_word_address`).** A 24C02 random read is a +dummy write of the word address, a repeated START, and a read-mode device +select; the read starts at the address just written. The existing model +cleared the address on every START, so every random read began at `$00`. +*Ultraman Club* reads its file table that way: with the address cleared it +reformatted the EEPROM at every boot and stopped on "no free block"; *Dragon +Ball Z* and *SD Gundam Wars* listed garbage save files. Mapper 157's 24C02 +keeps the address across a START, written from that protocol description; +with it all three reach their titles and *SD Gundam Wars* lists four empty +files. The flag is off for mappers 16 and 159, whose behaviour this release +does not change. **Open question:** the mapper-16 LZ93D50 games use the same +24C02 and may do random reads too; changing it there alters shipped frames, +so it needs its own change with an `EMULATION_EPOCH` bump and re-blessed +baselines. + +**Power-on EEPROM contents.** `$FF` throughout. Every game detects that as +unformatted and formats it on first boot (*Ultraman Club* writes `BA` then +its tables, *Dragon Ball Z* `BA` and `BB`). + +**The barcode reader** (`crates/rustynes-mappers/src/datach_barcode.rs`). The +page says only that bit 3 returns "a sequence of light and dark periods" as a +card is swiped. The core takes a 13- or 8-digit JAN / EAN code +(`Nes::scan_barcode`, validated at the boundary, wrong check digit included, +as `BarcodeScanError::Malformed`), draws its symbol from the published +EAN-13 / EAN-8 tables (95 / 67 modules), and presents it on bit 3 one module +per 1,000 CPU cycles, between 128 light modules of card margin on each side. +Bit 3 reads 1 over light and 0 over a bar and when no card is passing. The +polarity, the idle level, the module time and the margin are not documented +anywhere found; they were measured black-box against the games: + +| Choice | Measurement | +|---|---| +| light = 1, bar = 0 | With the opposite polarity no game reacted to any code at any speed. | +| idle = 0 (dark) | Idle-light left *Battle Rush*'s title showing one of its three menu items and ignoring A. | +| 1,000 cycles / module | Accepted windows (128-module margins): *Dragon Ball Z* Karin's room 200-3,000, *SD Gundam Wars* Barcode Lab 200-3,000, *J.League* team select 400-8,000. | +| 128-module margins | With 32, *Dragon Ball Z* needed 700 or more cycles per module. | + +A swipe is therefore about 0.2 s of emulated time, advanced from the mapper's +M2 hook (no wall clock), and its in-flight position is in the mapper's save +state. What the games did with a scan (pinned in `external_extended.rs`): +*Dragon Ball Z* Karin's room turns 4006381333931 into Dende and 96385074 into +Android 18 (HP 96000 / BP 86750 / DP 12750); *SD Gundam Wars*' Barcode Lab +turns 4006381333931 into Rafflesia and 96385074 into the RX-78 Gundam; +*J.League* answers an arbitrary code with "not a team card", having decoded +it. *Ultraman Club*, *Yu Yu Hakusho* and *Battle Rush* boot and play, but a +screen that reads a card was not reached in testing, so the reader is not +verified on them. + +**Save state.** The Bandai FCG blob keeps version 2. Mapper 157 appends a +tail after the fields every variant writes: the external SCL level, the +X24C01's 128 bytes when present, and the reader (module count, position, +cycles left, the modules bit-packed). No earlier blob of mapper 157 exists, so +nothing else's layout changed and no version moved; `PPU_SNAPSHOT_VERSION` and +`EMULATION_EPOCH` are untouched, because no frame of any game that loaded +before this change is different. + +**Movies and netplay.** A scan is an input that changes emulation and is not +recorded in `.rnm` movies or carried by netplay. A host must refuse a scan +while a movie records or plays or a netplay session runs, rather than let it +desynchronise. Recording scans is future work. + +**Open bus.** The six undriven bits of a `$6000-$7FFF` read are open bus on +the board and read 0 here, as on mapper 16: the bus consults a partial +driven-bit mask only for `$4020-$5FFF`. + +**Mutation proof.** Six mutants, each caught by the unit tests: an L-code +entry, a first-digit parity row, the bit-3 polarity, the external SCL bit, +the 24C02 `keeps_word_address` flag, and the EEPROMs' AND. The polarity and +the EEPROM flag were also caught by the game-level snapshots. + ### Mapper accuracy tiering (v1.2.0) Every supported family is classified `Core` / `Curated` / `BestEffort` by diff --git a/screenshots/external/mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).png b/screenshots/external/mapper-157-Datach/Datach - Battle Rush - Build Up Robot Tournament (Japan).png new file mode 100644 index 0000000000000000000000000000000000000000..00af9054cb12fcc8a151f30897674b697e739695 GIT binary patch literal 9211 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-157-Datach/Datach - J.League Super Top Players (Japan).png b/screenshots/external/mapper-157-Datach/Datach - J.League Super Top Players (Japan).png new file mode 100644 index 0000000000000000000000000000000000000000..d848c9c617ab4b0ed05dc6b7a5b456e0d20e0d22 GIT binary patch literal 3585 zcmcIncTm&m*8U|#hyoH6QKTe*UKDO9D=57S1duAo(h^1PQUlTrB!sr0D^(FhnhHxZ zNRz4wAc9D}EQF?n7O9~H2mum4_Rjam_s2K0Gk5O&=biUG^UOK#nKS1(&v|5HWh!{; z;wbH1Wzg(HyG**jB{9CP zW3^lhlrFH|HXlF#De~_pWtOS^ov^b{rP~x>=eB%$8+EwBhpY)*n%w*HW%q-z(1ZMf zRJ{M_%S4tCD^YX`kBg)pnaR6|I7X;V{fPumb z5I|oA3J~wX0N^tM03QGQfT_3j15NytYk;z-%>Qu~%)X$%q#h%A9xBO9UFbjX5ZKMt zz)nrxxq)8}?p16p_Y(#=B?d_EHO`ux?7Q=vgCht=9h zI_Pch8L)D@w=-PnIrXZ8RBO2pP21l}i#ku5JmV|W1ZO9RFPA_Px!PVc zL7>2cc^YBo=JtnSt6SW(3wv{5yTwzjR<4QwnRy^Ht(#Hn&AAh;uN1Dm?bA~o%`L>N zy1vyWN@nZwA{Z-OCUlR~O+#<{F8frd%PM%aIFlqbv?nro(pP-h{%YeL27pd zYl*SQ{kj^6&kC`tlxN+?S|zQed~1n|B^$LgF1Q{&V74qADjIl!zE=yk4n%YKhmav0 z!*5)XpYL%FTGT1GeEm^O_wHKOSij`RKp#XvEuU*2W9xSr*vN_IZr*rpu|P{Nf2@Q%Sg6M3tSo8;R7OshD0NTPP9fus z5jS{Ywb$ZE5X6p9vPQNJ`7x~MO4%D@0gI)FB*W8_LmBTRPH0Dm1ZovGcn(j^*Y>Yl z2|6Nyj4IUySbZeo{iv23%Imv=fYOpxqqpHwvFAQM+#3ovZ!p?hpG{mZmI?eEMMOfT zA$Br0le4>zeQ?HSUkOJlfHOXj-&io+`-10Re59ocA=glOwPsl&a3r9|VYJ8&p(Gor z?gEcKQh)M5zAriv(A{!spkH2B0j$9nZpy~X6$o##2410Soce_x;d5!Z0QA|(6jesFk(%Ak6^zb9&x%@ZmWlTbAsBF z`M2cg<@?1$D6-Kb`_H^kl3@44rN-C-z0Qv|utQwDC}Zy$&v%w0whqDZ*2=sF^1T|` zKE7M|yFt>4>>r2NQ zr>Wb`gTrp!SGW%Slmy@cU!}L0M+$9HDk>=Hc8`P>ZD_x1%ltJl$Dp+ZgEg{*6!nI1 zkq`{sF=_;tIU#--E8QiOT$;FL)}KnSjHt++oDSo&i{y##A2`P4gMrm2TBIAsWphjs z03$1Eou4lc}&ae=|A;9Ws|8zlp()g21$1C~u_h>Af zFn8rw=rPew-UMEWTG%U`pM*>4Cji?agtD`lDdkziuR1D+*^wo`(R=8bV_6fA=_tBI zCi+0{BYZf!lmX6;4(q-}o0!O_IpAmUbHXDwmRcKl}u3 z?W~)6rgmEKD)TXJBh8Wjk@oqp-+)fStv;KEZE0a|VXIVKES3iVw29rrgsv)WJlod?w&Uh~=d(cU+{ zv*f9i8AcVPdDQf;)>wOJfrx5XUffEmCGmrSn_d!E^~4~En0oJ5veV8aK`?O7#6N*| zGzQQQ4?M){17`wdgLSM-`?LXIPvSo}A^)uz+xxOYp-TQ=gZzL)_}L9GF@U56au?R~ zqxXVSLAem{@vG|{JbKRvGJSgy9D)#)9K*)xz*kJ>w+@yJ!|dEd_0X%ql~L+Kkk&T! zSbs(V`Qbi=C!2*8nbtD-I&KafKZ)O6fdwtv=(v1Myk-D^v#V$uTQl?#6&4ffYKncZ zG4Wi>Q8$+#oR$Q!>xbK)&L4f#y><13-pUJh?Bv5MFQ!$OVI8twKu=24yqV#-j*J zgp&=w(!XiN0;)0(p4*9&^K!Gdoj_O0!dD19O*OuZ0~&3hpeCAGS$p6f2OT_d!Pvh7 z9n2heh~f&mIn&ZRB>Ipop?ctOMgs2w10>w4@6$d%oML3BB6hBmYI0Hf?qhsch2cia z`McgA&|*>@Jw>DG66L-{jcxxQJ~w_i5qi**pEo44uONiL%GD0N-?Og@r(vNJ9s-#7 zq#_CXW^_zPSm_hT1G%{4KYmPi{v+9aK+l6hO!pjKOAKVizczIXi#5>yD=9~e@yfA3 z9ocT#YBDB-;cx(F)-`o8NTKj9kJB%DL|3luf!L~m8_}Q&-g?LZ4C|!@kCQou_7ecY zloP>`hj1D^c#QsnF0efSndQDrI|_yokDX2LbAW#^l0syl`m(EhgA2~@)qNT6Kq~@4 z^ocn2mwIy$q?J6v;m=FOhov?QOMA+(d^^^xeT#prcKMZ%(cLU1jnfPSAo(Dx+FX=u zCTX)j-tbiBnc`&`jhOUjSIev`B(Ly*vdw%{p5QYp6NHet512omf1*-d%F zeaaO^d^B(PZ4B``a_T*OvVR=P5eW-03^SUwwuVfm8#9!qt;TEPVj_R*>!#5{ z(M~3ze}vL0wH@;(`TwQL?uD%CV<-F<7b-ZayAeI_W}A0?{YsWI%cK2#u!|St``bcc zper;X#CB6i!ca!GLd}~UK-3g(>OOm-f;KB5a z(1Xhg;Na5*H7k>8Yhr+AA-{6E8;i%wZGh&h2P_H+6Sp|Pr5#@+ItTv42=bRz0t(u0 zV{{Ne-WOyE(kAsib%mkMm!-dsF+yxIY%n^wk^hM3s@0ZUS3CjbBd literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).png b/screenshots/external/mapper-157-Datach/Datach - SD Gundam - Gundam Wars (Japan).png new file mode 100644 index 0000000000000000000000000000000000000000..e5c47e650c6da4660aa1f3ab01460b2ad617b65a GIT binary patch literal 3435 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).png b/screenshots/external/mapper-157-Datach/Datach - Ultraman Club - Supokon Fight! (Japan).png new file mode 100644 index 0000000000000000000000000000000000000000..f9fe3515a34aacfa2d426d6e00071947e0ff0031 GIT binary patch literal 4599 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Ëhm24bO8HMlBwj8h{6zSKr*cxa4tg8HBU3 z$4Nvk-bl#Ey-g9tXSbL6Wn*F0e(Q+uEJjUzKY`x=VpzW`YbWj!)P(fLj-=~PeP!Dq zOxSSAidihsyY-}L?GZYU89l|Rr-1hzL@y54)M3ah8msN#=SC*<0FMbJF(>r}F(X){ z?y@sn#r9PRnSUtecoYPd=(Xk~T$i~jB_uwEZ(Qh2`qEIX0^08 zYtG=`h*Wpxiri4O@wTc2gfH0X%e3Sm8y#gwg8LWV{i3l7fed%@yssO*V#11n)%FM< zu`r^eeP#K@_}S1KRJwkbXnScXo9(I0xw+^;{9mVjRi(Xqlr*j`bKVg8Oo!1z>kTX; zg&6sp5USK^7xRACcTEJVA?LPdxBQqZRpw`?`Kmg@+QRD`U2d_i8I_{T!j^Dig*a@E z=*6bg7-iM}LivbOO5FORA|o4WSi`EVaIy61Tbzm^#P0_CIHcMn&pWWrsiM2H&iJ0w zaNuBDKJ(egMU`_!L-7BI9sDy1`M1%Be>H-1p7iFhPJcr$FZe14WbOo$jFdr;?jN_; znugrjVfkYVK&%Jkk4J9*<^cPX{&;_ujg1*p_DJOaJ2JupaA#au++3Uxc~QRPxxIi6 NIA?yrtlHEw{y$7Kgc|?= literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).png b/screenshots/external/mapper-157-Datach/Datach - Yu Yu Hakusho - Bakutou Ankoku Bujutsukai (Japan).png new file mode 100644 index 0000000000000000000000000000000000000000..8d3f398e77ca76f572a33ce08f75e00df17e4463 GIT binary patch literal 4627 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 M_Of-wUmg$t1-j4P?EnA( literal 0 HcmV?d00001 From c797775aa8cbabc82c94adf3ad5480f062232a07 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 21:34:30 -0400 Subject: [PATCH 014/104] feat(mappers): add mapper 207 (Taito X1-005 with CHR A17 on CIRAM A10) Mapper 207 is the board of Fudou Myouou Den. Written from the vendored NESdev page INES_Mapper_207; no reference-emulator source was read. It "modifies Taito's X1-005 (80) in the exact same way that TLSROM (118) differs from MMC3 (4)": CHR A17 drives CIRAM A10. The page's register list gives $7EF0 and $7EF1 as [MCCC CCCC], M selecting the CIRAM page for the top row ($2000/$2400, from $7EF0) and the bottom row ($2800/$2C00, from $7EF1), so 1scA / 1scB / H are all reachable; $7EF6-$7EF7, the old mirroring register, is ignored. Implementation: a construction-time variant of the project's own TaitoX1005 (m080_taito_x1_005.rs, no provenance header), TaitoX1005::new_m207. The board now overrides nametable_address (identical to the trait default on mapper 80) so the PPU sees the per-row page; current_mirroring reports MapperControlled on 207. CHR/PRG banking, the 128-byte battery RAM and the save-state layout are mapper 80's, whose path is unchanged (a test pins mapper 80 still honouring $7EF6 and ignoring bit 7). Harness: a new InputScript::HeldStart (common/mod.rs) holds START for N frames at given frames, with one checkpoint at the end. The game's START / CONTINUE menu ignores the one-frame taps every existing script uses, and neither the title screen nor the intro shows the nametable select: both draw identically with the page forced to 0. Verification - Red first: the three new parse-driven tests failed with UnsupportedMapper(207) before the dispatch arm. 9 m080 tests pass. - Dump: Fudou Myouou Den (Japan) (En) (1.0), the English translation, which carries a 207 header. The original Japanese GoodNES [!] is headered 80 and the game database also says 80 for its CRC, so it never reaches this board. The translation boots through the intro to the title ("The Acala Legend") and, with START held at f1900 and f2100, into stage 1 with the status bar under the playfield (f3000, the snapshot frame). D23 row, screenshot and external_coverage snapshot committed. - Mutation: forcing the CIRAM page to 0 failed the unit tests and moved the f3000 frame (5636d4a9162fab33 -> 61dbf8fbfde166eb). Swapping the two rows' source registers failed "m207_state_round_trips..." ([1,1,0,0] vs [0,0,1,1]) but not the boot frame; taking the page from bit 6 failed the unit test only. The stage-1 frame uses one page for both rows, so it distinguishes "page from the register" from "page fixed", not which row reads which register; the unit tests pin that. Tier: Curated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 6 + .../rustynes-mappers/src/m080_taito_x1_005.rs | 139 +++++++++++++++++- crates/rustynes-mappers/src/tier.rs | 6 +- .../rustynes-test-harness/tests/common/mod.rs | 40 ++++- .../tests/external_extended.rs | 21 +++ ...207_Fudou_Myouou_Den_Japan_En_1_0_zip.snap | 13 ++ ...ended__extended_m207_fudou_myouou_den.snap | 12 ++ docs/compatibility.md | 1 + docs/mappers.md | 1 + .../Fudou Myouou Den (Japan) (En) (1.0).png | Bin 0 -> 8677 bytes 10 files changed, 230 insertions(+), 9 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_207_TaitoX1_005_207_Fudou_Myouou_Den_Japan_En_1_0_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m207_fudou_myouou_den.snap create mode 100644 screenshots/external/mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 1ca62f71..69b4e337 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1590,6 +1590,12 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { Rambo1::new_m158(prg_rom, chr_rom) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // 207: Taito X1-005 with CHR A17 on CIRAM A10 (*Fudou Myouou Den*; + // `m080_taito_x1_005.rs`, `TaitoX1005::new_m207`). + 207 => Box::new( + TaitoX1005::new_m207(prg_rom, chr_rom) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/m080_taito_x1_005.rs b/crates/rustynes-mappers/src/m080_taito_x1_005.rs index b4b9d535..a300630b 100644 --- a/crates/rustynes-mappers/src/m080_taito_x1_005.rs +++ b/crates/rustynes-mappers/src/m080_taito_x1_005.rs @@ -34,6 +34,19 @@ //! reset bank and blanked `Kyonshiizu 2`; `$7EF6` polarity is 0=Horz/1=Vert.) //! //! See `docs/mappers.md` §Mapper coverage matrix. +//! +//! # Mapper 207 (*Fudou Myouou Den*) +//! +//! Built with [`TaitoX1005::new_m207`]. Per `nesdev_wiki/INES_Mapper_207.xhtml` +//! the board "modifies Taito's X1-005 (80) in the exact same way that TLSROM +//! (118) differs from MMC3 (4)": the chip's CHR A17 output drives CIRAM A10. +//! The page's register list gives `$7EF0` and `$7EF1` as `[MCCC CCCC]`, with M +//! (bit 7, which is CHR A17 of the 2 KiB bank) selecting the CIRAM page for +//! the top (`$7EF0`: `$2000`, `$2400`) and bottom (`$7EF1`: `$2800`, +//! `$2C00`) nametable rows, "thus [achieving] 1scA/1scB/H controllable +//! mirroring". `$7EF6-$7EF7`, the "old mirroring control register", is +//! ignored. Everything else is mapper 80's. The variant is a construction-time +//! property, not state, so the save-state layout is unchanged. #![allow(clippy::cast_possible_truncation, clippy::doc_markdown)] @@ -70,6 +83,9 @@ pub struct TaitoX1005 { /// `$7EF8` / `$7EF9` enable-latch; RAM is readable/writable only when both /// hold `$A3`. ram_enable: [u8; 2], + /// Mapper 207: CIRAM A10 is CHR A17 (bit 7 of `$7EF0` / `$7EF1`), and + /// `$7EF6-$7EF7` are not connected. + ciram_from_chr_a17: bool, } impl TaitoX1005 { @@ -113,9 +129,23 @@ impl TaitoX1005 { prg_bank: [0, 1, 2], mirroring, ram_enable: [0, 0], + ciram_from_chr_a17: false, }) } + /// Construct the mapper 207 board (*Fudou Myouou Den*): an X1-005 whose + /// CHR A17 output selects the CIRAM page per nametable row (see the module + /// docs). The header's mirroring does not apply and is not taken. + /// + /// # Errors + /// + /// As [`Self::new`]. + pub fn new_m207(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result { + let mut m = Self::new(prg_rom, chr_rom, Mirroring::MapperControlled)?; + m.ciram_from_chr_a17 = true; + Ok(m) + } + const fn ram_enabled(&self) -> bool { self.ram_enable[0] == RAM_MAGIC && self.ram_enable[1] == RAM_MAGIC } @@ -123,7 +153,14 @@ impl TaitoX1005 { const fn nametable_offset(&self, addr: u16) -> usize { let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8; let local = (addr as usize) & (NAMETABLE_SIZE - 1); - let physical = self.mirroring.physical_bank(table); + let physical = if self.ciram_from_chr_a17 { + // Mapper 207: rows 0-1 follow $7EF0 bit 7 (CHR slot 0), rows + // 2-3 follow $7EF1 bit 7 (CHR slot 2). + let reg = self.chr_1k[if table < 2 { 0 } else { 2 }]; + (reg >> 7) as usize + } else { + self.mirroring.physical_bank(table) + }; physical * NAMETABLE_SIZE + local } @@ -201,6 +238,8 @@ impl Mapper for TaitoX1005 { 0x7EF3 => self.chr_1k[5] = value, 0x7EF4 => self.chr_1k[6] = value, 0x7EF5 => self.chr_1k[7] = value, + // Mapper 207: the old mirroring register is ignored. + 0x7EF6 | 0x7EF7 if self.ciram_from_chr_a17 => {} 0x7EF6 | 0x7EF7 => { // $7EF6 bit 0: 0 = Horizontal, 1 = Vertical (nesdev mapper 080; // Mesen2 TaitoX1005). @@ -248,15 +287,29 @@ impl Mapper for TaitoX1005 { } } + fn nametable_address(&self, addr: u16) -> u16 { + // Identical to the trait default on mapper 80; mapper 207 needs its + // per-row CIRAM page. + u16::try_from(self.nametable_offset(addr)).unwrap_or(0) + } + fn current_mirroring(&self) -> Mirroring { - self.mirroring + if self.ciram_from_chr_a17 { + Mirroring::MapperControlled + } else { + self.mirroring + } } fn debug_info(&self) -> crate::mapper::MapperDebugInfo { let mut info = crate::mapper::MapperDebugInfo { - mapper_id: 80, - name: "Taito X1-005 (80)".into(), - mirroring: crate::mapper::mirroring_name(self.mirroring), + mapper_id: if self.ciram_from_chr_a17 { 207 } else { 80 }, + name: if self.ciram_from_chr_a17 { + "Taito X1-005, CHR A17 mirroring (207)".into() + } else { + "Taito X1-005 (80)".into() + }, + mirroring: crate::mapper::mirroring_name(self.current_mirroring()), ..Default::default() }; for (i, b) in self.prg_bank.iter().enumerate() { @@ -459,4 +512,80 @@ mod tests { assert_eq!(m.current_mirroring(), m2.current_mirroring()); assert_eq!(m2.cpu_read(0x7F10), 0xAB); } + + // --- Mapper 207 (Fudou Myouou Den) ---------------------------------- + + /// A mapper 207 image through `crate::parse`: 256 KiB PRG, 128 KiB CHR, + /// header says vertical. + fn m207() -> Box { + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = 16; + v[5] = 16; + v[6] = 0xF1; // 207 = 0xCF: low nibble 0xF, vertical + v[7] = 0xC0; + v.extend_from_slice(&synth_prg(32)); + v.extend_from_slice(&synth_chr_1k(128)); + crate::parse(&v) + .unwrap_or_else(|e| panic!("mapper 207 must parse: {e:?}")) + .1 + } + + fn pages(m: &dyn Mapper) -> [u16; 4] { + [0u16, 1, 2, 3].map(|n| m.nametable_address(0x2000 + n * 0x400 + 0x21) >> 10) + } + + #[test] + fn m207_7ef0_bit7_pages_the_top_row_and_7ef1_the_bottom() { + let mut m = m207(); + assert_eq!(m.current_mirroring(), Mirroring::MapperControlled); + m.cpu_write(0x7EF0, 0x80); + m.cpu_write(0x7EF1, 0x00); + assert_eq!(pages(m.as_ref()), [1, 1, 0, 0], "horizontal"); + m.cpu_write(0x7EF0, 0x02); + assert_eq!(pages(m.as_ref()), [0, 0, 0, 0], "1scA"); + m.cpu_write(0x7EF0, 0xFE); + m.cpu_write(0x7EF1, 0x81); + assert_eq!(pages(m.as_ref()), [1, 1, 1, 1], "1scB"); + assert_eq!(m.nametable_address(0x2C21) & 0x3FF, 0x21); + // The CHR banks are still the 2 KiB banks mapper 80 decodes. + m.cpu_write(0x7EF0, 0x86); + assert_eq!( + m.ppu_read(0x0000), + 6, + "$86 -> 1 KiB bank 134, wrapped to 6 of 128" + ); + assert_eq!(m.ppu_read(0x0400), 7); + } + + #[test] + fn m207_ignores_the_old_mirroring_register() { + let mut m = m207(); + m.cpu_write(0x7EF0, 0x00); + m.cpu_write(0x7EF1, 0x80); + m.cpu_write(0x7EF6, 0x01); + m.cpu_write(0x7EF7, 0x00); + assert_eq!(pages(m.as_ref()), [0, 0, 1, 1]); + assert_eq!(m.current_mirroring(), Mirroring::MapperControlled); + } + + #[test] + fn m207_state_round_trips_and_mapper_80_keeps_7ef6() { + let mut a = m207(); + a.cpu_write(0x7EF1, 0x80); + a.ppu_write(0x2800, 0x3C); + let blob = a.save_state(); + let mut b = m207(); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.ppu_read(0x2800), 0x3C); + assert_eq!(pages(b.as_ref()), [0, 0, 1, 1]); + let mut m80 = + TaitoX1005::new(synth_prg(16), synth_chr_1k(128), Mirroring::Horizontal).unwrap(); + m80.cpu_write(0x7EF6, 0x01); + assert_eq!(m80.current_mirroring(), Mirroring::Vertical); + m80.cpu_write(0x7EF0, 0x80); + assert_eq!(m80.nametable_address(0x2000) >> 10, 0, "80 ignores bit 7"); + assert_eq!(m80.nametable_address(0x2400) >> 10, 1); + } } diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 8a663d92..16ad66fd 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -142,7 +142,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // `docs/compatibility.md`). A separate arm so each release's batch // reads as one list; the bodies match on purpose. #[allow(clippy::match_same_arms)] - 158 | 172 | 173 => Some(MapperTier::Curated), + 158 | 172 | 173 | 207 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -250,8 +250,8 @@ mod tests { 178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". - 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, - 158, // v3.2.0 + 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, + 207, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index 4379fab2..c07c7012 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -338,6 +338,16 @@ pub mod external { free_run: u32, checkpoints: &'static [u32], }, + /// v3.2.0 — START HELD for `hold` frames from each frame index in + /// `presses` (0-based, ascending), idle otherwise, for `frames` frames; + /// one checkpoint at the end. For games whose menus ignore a one-frame + /// tap (*Fudou Myouou Den*'s START / CONTINUE menu reads the button + /// across several frames), so no single-tap script reaches gameplay. + HeldStart { + presses: &'static [u32], + hold: u32, + frames: u32, + }, } impl InputScript { @@ -347,7 +357,7 @@ pub mod external { #[must_use] pub const fn total_frames(&self) -> u32 { match *self { - Self::IdleOnly { frames } => frames, + Self::IdleOnly { frames } | Self::HeldStart { frames, .. } => frames, Self::StartTap { idle_pre, idle_post, @@ -959,6 +969,34 @@ pub mod external { maybe_emit(frame_counter, &nes); } } + InputScript::HeldStart { + presses, + hold, + frames, + } => { + for f in 0..frames { + let held = presses.iter().any(|&p| f >= p && f < p + hold); + nes.set_buttons( + 0, + if held { + Buttons::START + } else { + Buttons::empty() + }, + ); + pulse_vs_coin!(); + nes.run_frame(); + samples.extend(nes.drain_audio()); + frame_counter += 1; + } + let fb_hash = fnv1a64(nes.framebuffer()); + best_health = Some(best_of(best_health, frame_health(nes.framebuffer()))); + checkpoints.push(Checkpoint { + frame: frames, + fb_hash, + }); + dump_frame(&label_safe, &format!("f{frames}"), nes.framebuffer()); + } } // Defensive: ensure the framebuffer is the canonical size. diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 32293e82..a8222374 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1455,3 +1455,24 @@ fn extended_m158_alien_syndrome() { "extended_m158_alien_syndrome", ); } + +/// *Fudou Myouou Den*: START held three frames at f1900 (skips the intro to +/// the START / CONTINUE menu) and again at f2100 (starts the game), then play +/// to f3000, in the first stage with the status bar under the playfield. The +/// menu ignores one-frame taps, so `HeldStart`. Gameplay is where mapper +/// 207's CHR A17 nametable select shows: the title screen and the intro draw +/// identically with it forced to page 0, and this frame does not. +const FUDOU_IN_PLAY: InputScript = InputScript::HeldStart { + presses: &[1900, 2100], + hold: 3, + frames: 3000, +}; + +#[test] +fn extended_m207_fudou_myouou_den() { + check( + "mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).zip", + FUDOU_IN_PLAY, + "extended_m207_fudou_myouou_den", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_207_TaitoX1_005_207_Fudou_Myouou_Den_Japan_En_1_0_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_207_TaitoX1_005_207_Fudou_Myouou_Den_Japan_En_1_0_zip.snap new file mode 100644 index 00000000..76be671e --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_207_TaitoX1_005_207_Fudou_Myouou_Den_Japan_En_1_0_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).zip +rom_sha256=b47c17c4d77c885bfd1552d97d9d5cc66e8bb39d6f92b208a0fc70664610d7c2 +frames=1141 +fb_bytes=245760 +cycles=33949775 +audio_samples=836505 +audio_fnv1a64=23e066088a186753 +checkpoint f900 fb_fnv1a64=6b88614d9091fdae +checkpoint f1100 fb_fnv1a64=6b88614d9091fdae diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m207_fudou_myouou_den.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m207_fudou_myouou_den.snap new file mode 100644 index 00000000..3e68eea1 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m207_fudou_myouou_den.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).zip +rom_sha256=b47c17c4d77c885bfd1552d97d9d5cc66e8bb39d6f92b208a0fc70664610d7c2 +frames=3000 +fb_bytes=245760 +cycles=89311726 +audio_samples=2200623 +audio_fnv1a64=4ab1d2804c22401a +checkpoint f3000 fb_fnv1a64=5636d4a9162fab33 diff --git a/docs/compatibility.md b/docs/compatibility.md index d30e55b0..e3792792 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -376,3 +376,4 @@ in `crates/rustynes-test-harness/tests/external_extended.rs`. | 172 | 1991 Du Ma Racing (Ch) [!] | `4a2ec0f51526ad9659b0ede758216ea8f7acae7d49faaed5db46b6acc7be6915` | 600 | `screenshots/external/mapper-172-TXC-JV001/1991 Du Ma Racing (Ch) [!].png` | | 173 | Xiao Ma Li (Ch) [!] | `103e27a9b2e2c029fee9a221e2ffe35b5b0b4a3e529a4df88fd6d71f21cf56a4` | 600 | `screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png` | | 158 | Alien Syndrome (USA) (Unl) | `d676ef6f7aa3b042ae5a0c95a7f07fabb4c3a5fccb3767ae1950713e51189a47` | 2400 (attract demo, in play) | `screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png` | +| 207 | Fudou Myouou Den (Japan) (En) (1.0) (translation, 207 header) | `e33482a375923d692bb5ddd3be71dab63de6a786ec311e8dfc32417a19e48fb3` | 3000 (START held at 1900 and 2100; stage 1 in play) | `screenshots/external/mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).png` | diff --git a/docs/mappers.md b/docs/mappers.md index 247160a5..cad04660 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -852,6 +852,7 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 172 | — | Super Mega P-4070, JV001 (`INES_Mapper_172`, `INES_Mapper_136`) | Curated | — | `m172_m173_txc.rs`. The JV001's Input / Register / Output with CPU D0-D5 wired in reverse; Mode and Invert from CPU D5. `$8000-$FFFF` latches Output (CHR bank = Output & 3 in chip order) and sets mirroring from Invert (H when clear, V when set); the header's mirroring holds until then. PRG fixed 32 KiB. `$4100` reads drive D0-D5 only. *1991 Du Ma Racing* passes its protection check and plays. | | 173 | — | Idea-Tek ET-xx, 05-00002-010 (`INES_Mapper_173`) | Curated | — | Same module. P/R/Output 3 bits, S/C/V 1 bit; `$4100` reads `[xxxx SRRR]` with S XOR V, D4-D7 open bus. CHR bank = (Output & 1) \| (~V << 1), V read live. Hard-wired mirroring. *Xiao Ma Li*'s 8 KiB EPROM with `/PGM` on pin 27 is not modelled beyond wrapping (the page does not say what a disabled CHR read returns). The TXC *Mahjong Block* (CRC32 `0ACFC3CD`), commonly headered 173, is a mapper 132 board per both pages: as 173 it draws garbage, as the page predicts. | | 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | +| 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`HeldStart`: the menu ignores one-frame taps). | ### Mapper accuracy tiering (v1.2.0) diff --git a/screenshots/external/mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).png b/screenshots/external/mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).png new file mode 100644 index 0000000000000000000000000000000000000000..356d441ced273da8cb3005beeda14e76fd312d0c GIT binary patch literal 8677 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 literal 0 HcmV?d00001 From df128981b122ac7f0df0ed4cf943d50946ad8052 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 21:40:51 -0400 Subject: [PATCH 015/104] feat(mappers): add mapper 165 (MMC3 + MMC2-style CHR latch) Mapper 165 is the Waixing board of the Chinese Fire Emblem translations: an MMC3 whose CHR registers are used MMC2-style. Written from the vendored INES_Mapper_165.md (Disch's notes, the only description the page has) plus MMC2.md and MMC3.md, into mmc3_boards_ext.rs; no reference-emulator source was read. What the notes give, and what the model adds where they are silent - PRG, mirroring, "SRAM enabling" and IRQs are the MMC3's (Sharp). - CHR: $0000-$0FFF is R0 or R1 and $1000-$1FFF is R2 or R4, chosen by "the same latches that exist on MMC2". The latches are the MMC2's exactly: latch 0 on $0FD8 / $0FE8, latch 1 on $1FD8-$1FDF / $1FE8-$1FEF, switching after the read that hit them. - "CHR page 0 is CHR-RAM, other pages are CHR-ROM", with 4 KiB of RAM beside the ROM. A page is the register value over 4 (the registers count 1 KiB, a window is 4 KiB), and page n reads ROM at n x 4 KiB. - 8 KiB of work RAM by default, since the notes put "SRAM enabling" among the MMC3 features (the header leaves it out on iNES 1.0). - $8000 bit 7 (CHR inversion) is not in the notes' diagram and has no effect. - A CHR read can switch banks, so chr_reads_are_pure() is false and the latches are in the save state; mapper.rs's impure-set audit gains 165 (that list exists to make exactly this addition deliberate). - Mmc3 gains a pub(crate) bank_register() accessor (R0-R7 as written, with no inversion or pairing); nothing else in m004_mmc3.rs changes. Two choices were settled black-box on the dumps (rung 3, output only) - Latch triggers. The notes say the game reads "$xFD0 or $xFE0 via $2007" to toggle a latch by hand. Triggering on the whole $xFD0-$xFDF / $xFE0-$xFEF span draws a one-tile vertical bar before every line of intro text (the switching tile's high plane comes from the new bank); the MMC2's triggers and an MMC4-style 8-byte span both draw the same screens clean. The MMC2's are used, as the notes' "same latches" says. - Work RAM. Without it Fire Emblem Gaiden (Ch) draws its title as a field of garbage tiles; with it the title (the swordsman portrait and the logo) draws correctly. Verification - Red first: a parse test of 165 failed with the id unsupported, then passed. - 7 unit tests: the latch-selected pages, including that the trigger read itself comes from the old page; latch 0 ignoring $0FE9 / $0FE0; page 0 as one 4 KiB RAM in both windows, ROM pages ignoring writes; inversion ignored; MMC3 PRG, mirroring, default 8 KiB SRAM gated by $A001, IRQ; impure reads and a state round trip that restores both latches and the RAM page. - Boot snapshots (START at frames 700 and 1101, 2202 frames, checkpoints 900 / 1700 / 2100): Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch) passes the Waixing logo (drawn from the CHR-RAM page), shows its title and runs the intro (the sword with "AT FIRST. THERE ARE DARK DRAGON, FALCON SWORD AND" at f2100, the committed screenshot); Fire Emblem Gaiden (Ch) shows and holds its title. A longer probe with A presses reaches the new-game menu and the class description cards (Chinese text, unit sprites), all clean; not snapshotted. - Tier: Curated on the two D23 records. Under v2.9.6's page-only rule a Disch-notes page was BestEffort; this release's rule is the D23 record, and the docs say so. - Mutation: latch 1 at $FE selecting R2 instead of R4. Caught by the unit test ("R4 after $1FEC", left 16 right 20) and by the Fire Emblem intro checkpoints f1700 and f2100 (28331e0f... -> 0c9d5142..., 4e8ecfc2... -> caf3b9bd...); the Gaiden title snapshot is unaffected. Restored from a copy. Not verified: gameplay maps and battles; which screens the game draws through a manual $2007 latch toggle. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 2 + crates/rustynes-mappers/src/m004_mmc3.rs | 8 + crates/rustynes-mappers/src/mapper.rs | 3 +- .../rustynes-mappers/src/mmc3_boards_ext.rs | 181 +++++++++++++++--- .../src/mmc3_boards_ext_tests.rs | 142 ++++++++++++++ crates/rustynes-mappers/src/tier.rs | 4 +- .../tests/external_extended.rs | 30 +++ ...xtended__extended_m165_fire_emblem_ch.snap | 14 ++ ...__extended_m165_fire_emblem_gaiden_ch.snap | 14 ++ docs/compatibility.md | 2 + docs/mappers.md | 1 + ... Ankoku Ryuu to Hikari no Tsurugi (Ch).png | Bin 0 -> 3591 bytes .../Fire Emblem Gaiden (Ch).png | Bin 0 -> 6676 bytes 13 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_ch.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_gaiden_ch.snap create mode 100644 screenshots/external/mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).png create mode 100644 screenshots/external/mapper-165-FireEmblemCh/Fire Emblem Gaiden (Ch).png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index bb911dd5..4cf91d9a 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -1418,6 +1418,8 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { chr_rom, &h, )?, + // MMC3 + MMC2-style CHR latches and a 4 KiB CHR-RAM page. + 165 => mmc3_ext(Mmc3ExtBoardKind::M165, prg_rom, chr_rom, &h)?, // Sugar Softec UNL-8237 (215.0) / UNL-8237A (215.1). 215 => mmc3_ext( Mmc3ExtBoardKind::M215 { diff --git a/crates/rustynes-mappers/src/m004_mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs index 3e853936..909269a1 100644 --- a/crates/rustynes-mappers/src/m004_mmc3.rs +++ b/crates/rustynes-mappers/src/m004_mmc3.rs @@ -428,6 +428,14 @@ impl Mmc3 { self.prg_ram_enabled && !self.prg_ram_protect } + /// Bank register R`r` (`r` = 0-7) exactly as written through `$8001`, + /// with no CHR-inversion or 2 KiB pairing applied. For boards that use + /// the MMC3's registers in a different banking scheme (mapper 165 reads + /// R0 / R1 / R2 / R4 as 4 KiB pages, MMC2 style); v3.2.0. + pub(crate) const fn bank_register(&self, r: usize) -> u8 { + self.regs[r & 7] + } + /// Resolve a CPU PRG address (`$8000-$FFFF`) to a byte offset in /// `prg_rom`. Implements PRG modes 0 and 1. fn prg_offset(&self, addr: u16) -> usize { diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index e71c6c48..9a73d4ea 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -878,7 +878,8 @@ mod caps_tests { assert!(checked > 150, "only {checked} boards were checked"); assert_eq!( impure, - vec![9, 10, 35, 90, 96, 163, 209, 211], + // v3.2.0: 165 (MMC3 + MMC2-style CHR latches). + vec![9, 10, 35, 90, 96, 163, 165, 209, 211], "the impure set moved" ); } diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext.rs b/crates/rustynes-mappers/src/mmc3_boards_ext.rs index 11ad39c1..e4cbc275 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext.rs @@ -1,8 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later //! MMC3-clone boards added in v3.2.0, written from their NESdev pages: //! mappers 114 and 182 (the SuperGame / Hosenkan register scramblers) and -//! 215 (Sugar Softec's selectable scrambler, UNL-8237 / UNL-8237A), and 197 -//! (an MMC3 rewired for 2 / 4 KiB CHR banks). +//! 215 (Sugar Softec's selectable scrambler, UNL-8237 / UNL-8237A), 197 +//! (an MMC3 rewired for 2 / 4 KiB CHR banks) and 165 (an MMC3 with MMC2-style +//! CHR latches). //! //! These are, like the boards in `mmc3_boards.rs`, a stock MMC3 with a //! board-specific layer in front of it or behind it. The difference that @@ -106,6 +107,43 @@ //! MMC3's A17. iNES 1.0 images take submapper 0. The page names a PRG CRC32 //! for its one submapper 1 and one submapper 2 game; detecting them is the //! per-game database's job, not the board's. +//! +//! ## Mapper 165 (`INES_Mapper_165.md`) +//! +//! The page is Disch's notes only: "a strange MMC2+MMC3 hybrid". PRG, PRG-RAM +//! enable, mirroring "and even IRQs" are the MMC3's; the CHR registers are +//! used MMC2-style, two 4 KiB windows each chosen by a tile latch: +//! +//! - `$0000-$0FFF`: R0 while latch 0 is `$FD`, R1 while it is `$FE`; +//! - `$1000-$1FFF`: R2 while latch 1 is `$FD`, R4 while it is `$FE`. +//! +//! "CHR page 0 is CHR-RAM, other pages are CHR-ROM", with 4 KiB of CHR-RAM +//! beside the ROM, and "SRAM enabling" as on the MMC3, so the board has 8 KiB +//! of work RAM even under an iNES 1.0 header. Three readings are this +//! model's, stated where the notes are silent or loose: +//! +//! - **A page is the register value over 4.** The MMC3's registers count +//! 1 KiB units and a window is 4 KiB, so page = R >> 2 (MMC3 CHR A12 and +//! up), and page `n` reads CHR-ROM at `n * 4 KiB` (page 0's ROM is +//! shadowed by the RAM). +//! - **The latches are the MMC2's.** "The same latches that exist on MMC2 +//! exist on this mapper as well": latch 0 switches on `$0FD8` / `$0FE8` +//! exactly, latch 1 on `$1FD8-$1FDF` / `$1FE8-$1FEF`, after the read that +//! hit them (`MMC2.md`). The notes also say the game reads "`$xFD0` or +//! `$xFE0` via `$2007`" to toggle a latch by hand; that is not taken as a +//! trigger address. Measured on the Fire Emblem translation: triggering on +//! all of `$xFD0-$xFDF` draws a stray vertical bar before every line of +//! intro text (the high plane of the switching tile comes from the new +//! bank), while the MMC2's triggers draw the same screens (title, intro +//! text, unit cards) clean. Which of them the game draws through a manual +//! `$2007` toggle was not determined. +//! - **Work RAM is required, not optional.** Without it the *Fire Emblem +//! Gaiden* translation's title screen is garbage tiles: it builds the title +//! in RAM it reads back. +//! +//! `$8000` bit 7 (CHR inversion) is not part of the notes' diagram and has +//! no effect here. Because a pattern read can switch banks, the board reports +//! impure CHR reads ([`Mapper::chr_reads_are_pure`]). #![allow( clippy::cast_possible_truncation, @@ -128,7 +166,8 @@ const CHR_BANK_1K: usize = 0x0400; const SAVE_STATE_VERSION: u8 = 1; /// Fixed head of the save state: version, id, variant tag, four registers, -/// one latch byte, and the embedded MMC3 state's length. +/// the latch byte (165: bit 0 / bit 1 = latch 0 / 1 at `$FE`), and the +/// embedded MMC3 state's length. const HEAD: usize = 3 + 4 + 1 + 4; /// Which board an [`Mmc3BoardExt`] models. @@ -152,6 +191,9 @@ pub enum ExtBoard { /// NES 2.0 submapper. sub: u8, }, + /// Mapper 165, `INES_Mapper_165.md`: MMC3 with MMC2-style CHR latches + /// and a 4 KiB CHR-RAM page. + M165, } impl ExtBoard { @@ -160,6 +202,7 @@ impl ExtBoard { Self::M114 { .. } => 114, Self::M215 { .. } => 215, Self::M197 { .. } => 197, + Self::M165 => 165, } } @@ -173,6 +216,7 @@ impl ExtBoard { Self::M197 { sub: 2 } => "MMC3 2/4 KiB CHR (197.2)", Self::M197 { sub: 3 } => "MMC3 2/4 KiB CHR + outer PRG (197.3)", Self::M197 { .. } => "MMC3 2/4 KiB CHR (197.0)", + Self::M165 => "MMC3 + MMC2 latch (165)", } } @@ -185,6 +229,7 @@ impl ExtBoard { Self::M215 { sub1: false } => 2, Self::M215 { sub1: true } => 3, Self::M197 { sub } => 4 + sub, + Self::M165 => 8, } } } @@ -259,6 +304,17 @@ const M215_SCRAMBLE: [Scramble; 8] = [ /// leaves the high nibble undefined; it is taken as 0. const M215_OUTER_POWER_UP: u8 = 0x0F; +/// Mapper 165's CHR-RAM: one 4 KiB page. +const M165_CHR_RAM: usize = 0x1000; + +/// Where a pattern-table access lands. +enum Chr { + /// A byte offset into CHR-ROM (or the whole-image CHR-RAM). + Rom(usize), + /// A byte offset into the board's separate CHR-RAM (165). + Ram(usize), +} + /// The position of an `$E001`-masked MMC3 address in a [`Scramble`] row. const fn scramble_slot(addr: u16) -> usize { ((((addr >> 13) & 3) << 1) | (addr & 1)) as usize @@ -307,6 +363,10 @@ pub struct Mmc3BoardExt { /// 215: `regs[0]` = `$5000`, `regs[1]` = `$5001`, `regs[2]` = `$5007`. /// 197.3: `regs[0]` = the `$6000` outer register. regs: [u8; 4], + /// 165: the two CHR latches, `true` = `$FE` (index 0: `$0000-$0FFF`). + latch: [bool; 2], + /// 165: the 4 KiB CHR-RAM page; empty on every other board. + chr_ram: Box<[u8]>, } impl Mmc3BoardExt { @@ -348,13 +408,22 @@ impl Mmc3BoardExt { ExtBoard::M114 { .. } => Mmc3Revision::Nec, // 197: "PRG-ROM, nametable mirroring and IRQs function as in // mapper 4." - ExtBoard::M215 { .. } | ExtBoard::M197 { .. } => Mmc3Revision::Sharp, + // 165: "Register style, PRG, mirroring, ?and even IRQs? of MMC3". + ExtBoard::M215 { .. } | ExtBoard::M197 { .. } | ExtBoard::M165 => Mmc3Revision::Sharp, }; // Unknown 197 submappers are wired as submapper 0. let board = match board { ExtBoard::M197 { sub } if sub > 3 => ExtBoard::M197 { sub: 0 }, b => b, }; + // 165: "SRAM enabling ... function as they do on your vanilla MMC3", + // so the board has the MMC3's 8 KiB of work RAM (the Fire Emblem + // translations save to it) even when an iNES 1.0 header says nothing. + let wram_bytes = if board == ExtBoard::M165 && wram_bytes == 0 { + 0x2000 + } else { + wram_bytes + }; let mut regs = [0u8; 4]; if matches!(board, ExtBoard::M215 { .. }) { regs[1] = M215_OUTER_POWER_UP; @@ -367,6 +436,16 @@ impl Mmc3BoardExt { chr_is_ram, wram: vec![0u8; wram_bytes].into_boxed_slice(), regs, + latch: [false; 2], + chr_ram: vec![ + 0u8; + if board == ExtBoard::M165 { + M165_CHR_RAM + } else { + 0 + } + ] + .into_boxed_slice(), }) } @@ -396,7 +475,7 @@ impl Mmc3BoardExt { ExtBoard::M197 { sub: 3 } if self.regs[0] & 0x10 != 0 => { (raw & 0x0F) | (usize::from(self.regs[0] & 1) << 4) } - ExtBoard::M197 { .. } => raw, + ExtBoard::M197 { .. } | ExtBoard::M165 => raw, } } @@ -435,13 +514,46 @@ impl Mmc3BoardExt { // MMC3 CHR A10-A17 drive ROM A11-A18; PPU A10 drives ROM A10. ((self.core.chr_bank_1k(seen) & 0xFF) << 1) | usize::from((addr >> 10) & 1) } + // 165 resolves through `m165_page`; this arm serves `chr_phys`'s + // ROM path only and is never reached for a RAM page. + ExtBoard::M165 => (self.m165_page(addr) << 2) | usize::from((addr >> 10) & 3), } } - fn chr_offset(&self, addr: u16) -> usize { + /// Mapper 165: the 4 KiB page a pattern-table address selects, from the + /// latch of its half (R0 / R1 below `$1000`, R2 / R4 above). + fn m165_page(&self, addr: u16) -> usize { + let high = addr & 0x1000 != 0; + let reg = match (high, self.latch[usize::from(high)]) { + (false, false) => 0, + (false, true) => 1, + (true, false) => 2, + (true, true) => 4, + }; + usize::from(self.core.bank_register(reg) >> 2) + } + + /// Resolve a pattern-table address to CHR-ROM or the 165 CHR-RAM page. + fn chr_target(&self, addr: u16) -> Chr { let addr = addr & 0x1FFF; + if self.board == ExtBoard::M165 && self.m165_page(addr) == 0 { + return Chr::Ram(usize::from(addr) & (M165_CHR_RAM - 1)); + } let bank = mirror_bank(self.chr_bank(addr), self.chr.len() / CHR_BANK_1K); - bank * CHR_BANK_1K + (usize::from(addr) & (CHR_BANK_1K - 1)) + Chr::Rom(bank * CHR_BANK_1K + (usize::from(addr) & (CHR_BANK_1K - 1))) + } + + /// Mapper 165's latch update, after the read that hit a trigger: the + /// MMC2's triggers (`MMC2.md`), latch 0 on `$0FD8` / `$0FE8` exactly and + /// latch 1 on `$1FD8-$1FDF` / `$1FE8-$1FEF`. + fn m165_update_latch(&mut self, addr: u16) { + match addr & 0x1FFF { + 0x0FD8 => self.latch[0] = false, + 0x0FE8 => self.latch[0] = true, + 0x1FD8..=0x1FDF => self.latch[1] = false, + 0x1FE8..=0x1FEF => self.latch[1] = true, + _ => {} + } } fn write_high(&mut self, addr: u16, value: u8) { @@ -451,7 +563,7 @@ impl Mmc3BoardExt { ExtBoard::M215 { .. } => { unscramble(&M215_SCRAMBLE[usize::from(self.regs[2] & 7)], addr, value) } - ExtBoard::M197 { .. } => (addr, value), + ExtBoard::M197 { .. } | ExtBoard::M165 => (addr, value), }; self.core.cpu_write(addr, value); } @@ -536,30 +648,43 @@ impl Mapper for Mmc3BoardExt { } } + fn chr_reads_are_pure(&self) -> bool { + // 165: a read of tile $FD / $FE switches a CHR latch. + self.board != ExtBoard::M165 + } + fn chr_phys(&self, addr: u16) -> Option { - if self.chr_is_ram { - None - } else { - u32::try_from(self.chr_offset(addr)).ok() + match self.chr_target(addr) { + Chr::Rom(off) if !self.chr_is_ram => u32::try_from(off).ok(), + _ => None, } } fn ppu_read(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; - if addr < 0x2000 { - self.chr[self.chr_offset(addr)] - } else { - self.core.ppu_read(addr) + if addr >= 0x2000 { + return self.core.ppu_read(addr); + } + let value = match self.chr_target(addr) { + Chr::Rom(off) => self.chr[off], + Chr::Ram(off) => self.chr_ram[off], + }; + if self.board == ExtBoard::M165 { + self.m165_update_latch(addr); } + value } fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if addr >= 0x2000 { self.core.ppu_write(addr, value); - } else if self.chr_is_ram { - let off = self.chr_offset(addr); - self.chr[off] = value; + return; + } + match self.chr_target(addr) { + Chr::Ram(off) => self.chr_ram[off] = value, + Chr::Rom(off) if self.chr_is_ram => self.chr[off] = value, + Chr::Rom(_) => {} } } @@ -598,23 +723,31 @@ impl Mapper for Mmc3BoardExt { for (i, r) in self.regs.iter().enumerate() { info.extra.push((format!("board{i}"), format!("{r:#04x}"))); } + if self.board == ExtBoard::M165 { + for (i, fe) in self.latch.iter().enumerate() { + let v = if *fe { "FE" } else { "FD" }; + info.extra.push((format!("latch{i}"), v.to_string())); + } + } info } fn save_state(&self) -> Vec { let core = self.core.save_state(); let chr = if self.chr_is_ram { self.chr.len() } else { 0 }; - let mut out = Vec::with_capacity(HEAD + core.len() + chr + self.wram.len()); + let mut out = + Vec::with_capacity(HEAD + core.len() + chr + self.chr_ram.len() + self.wram.len()); out.push(SAVE_STATE_VERSION); out.push(self.board.id() as u8); out.push(self.board.state_tag()); out.extend_from_slice(&self.regs); - out.push(0); // Reserved for a board latch byte. + out.push(u8::from(self.latch[0]) | (u8::from(self.latch[1]) << 1)); out.extend_from_slice(&(core.len() as u32).to_le_bytes()); out.extend_from_slice(&core); if self.chr_is_ram { out.extend_from_slice(&self.chr); } + out.extend_from_slice(&self.chr_ram); out.extend_from_slice(&self.wram); out } @@ -643,7 +776,7 @@ impl Mapper for Mmc3BoardExt { // a 32-bit target, pass the check, and panic at the slice. let expected = HEAD .checked_add(core_len) - .and_then(|n| n.checked_add(chr + self.wram.len())); + .and_then(|n| n.checked_add(chr + self.chr_ram.len() + self.wram.len())); if expected != Some(data.len()) { return Err(MapperError::WrongLength { expected: expected.unwrap_or(usize::MAX), @@ -654,10 +787,14 @@ impl Mapper for Mmc3BoardExt { self.core.load_state(&data[cur..cur + core_len])?; cur += core_len; self.regs.copy_from_slice(&data[3..7]); + self.latch = [data[7] & 1 != 0, data[7] & 2 != 0]; if self.chr_is_ram { self.chr.copy_from_slice(&data[cur..cur + chr]); cur += chr; } + let n = self.chr_ram.len(); + self.chr_ram.copy_from_slice(&data[cur..cur + n]); + cur += n; self.wram.copy_from_slice(&data[cur..]); Ok(()) } diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs index 84c50cef..eb508a6c 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs @@ -610,3 +610,145 @@ fn m197_state_round_trip_and_variant_refusal() { let m = board(ExtBoard::M197 { sub: 9 }, 32, 512); assert_eq!(m.board, ExtBoard::M197 { sub: 0 }); } + +// --------------------------------------------------------------------------- +// Mapper 165 (`INES_Mapper_165.md`, Disch's notes). +// --------------------------------------------------------------------------- + +#[test] +fn m165_parses() { + assert!( + crate::parse(&nes2_image(165, 0, 32, 16)).is_ok(), + "mapper 165 must construct" + ); +} + +/// Load R0 = `$08`, R1 = `$0C`, R2 = `$10`, R4 = `$14` (4 KiB pages 2, 3, 4, +/// 5 at a page = R >> 2) and R6 = 3. +fn m165_load_regs(m: &mut Mmc3BoardExt) { + for (r, v) in [(0u8, 0x08u8), (1, 0x0C), (2, 0x10), (4, 0x14), (6, 3)] { + m.cpu_write(0x8000, r); + m.cpu_write(0x8001, v); + } +} + +/// "`$0000`: R0 or R1, `$1000`: R2 or R4", picked by the MMC2 latches; a +/// page is the register over 4, so 1 KiB bank = page * 4 + slot. The latch +/// switches after the read that hits the trigger (`MMC2.md`), so that read +/// still comes from the old page. +#[test] +fn m165_latches_select_the_mmc2_style_pages() { + let mut m = board(ExtBoard::M165, 32, 128); + m165_load_regs(&mut m); + // Power-on latches are $FD: R0 and R2. + assert_eq!(chr_at(&mut m, 0x0000), 8); + assert_eq!(chr_at(&mut m, 0x0C00), 11); + assert_eq!(chr_at(&mut m, 0x1000), 16); + // $0FE8 sets latch 0 to $FE: the read itself is from R0's page. + assert_eq!(m.ppu_read(0x0FE8), m.chr[11 * CHR_BANK_1K + 0x3E8]); + assert_eq!(chr_at(&mut m, 0x0000), 12, "R1 after $0FE8"); + // $0FD8 sets it back. + m.ppu_read(0x0FD8); + assert_eq!(chr_at(&mut m, 0x0000), 8, "R0 after $0FD8"); + // Latch 1 responds to the whole $1FE8-$1FEF / $1FD8-$1FDF ranges. + m.ppu_read(0x1FEC); + assert_eq!(chr_at(&mut m, 0x1000), 20, "R4 after $1FEC"); + assert_eq!(chr_at(&mut m, 0x0000), 8, "latch 0 untouched"); + m.ppu_read(0x1FDF); + assert_eq!(chr_at(&mut m, 0x1000), 16, "R2 after $1FDF"); +} + +/// Latch 0 "only responds to one address" (`MMC2.md`): `$0FE9` and `$0FE0` +/// do not switch it. +#[test] +fn m165_latch_0_triggers_on_exact_addresses_only() { + let mut m = board(ExtBoard::M165, 32, 128); + m165_load_regs(&mut m); + m.ppu_read(0x0FE9); + m.ppu_read(0x0FE0); + assert_eq!(chr_at(&mut m, 0x0000), 8); + m.ppu_read(0x0FE8); + assert_eq!(chr_at(&mut m, 0x0000), 12); +} + +/// "CHR page 0 is CHR-RAM": one 4 KiB page, the same RAM in either window; +/// a ROM page ignores writes. +#[test] +fn m165_page_0_is_chr_ram() { + let mut m = board(ExtBoard::M165, 32, 128); + m165_load_regs(&mut m); + m.ppu_write(0x0123, 0x5A); + assert_ne!(m.ppu_read(0x0123), 0x5A, "page 2 is ROM"); + m.cpu_write(0x8000, 0); + m.cpu_write(0x8001, 0x03); // R0 = 3: page 0 + m.ppu_write(0x0123, 0x5A); + assert_eq!(m.ppu_read(0x0123), 0x5A); + m.cpu_write(0x8000, 2); + m.cpu_write(0x8001, 0x00); // R2 = 0: page 0 at $1000 too + assert_eq!(m.ppu_read(0x1123), 0x5A, "the same 4 KiB page"); + assert_eq!(m.chr_phys(0x1123), None, "RAM has no ROM offset"); +} + +/// The page ignores `$8000` bit 7 (CHR inversion): the notes' diagram has +/// R0/R1 at `$0000` and R2/R4 at `$1000` unconditionally. +#[test] +fn m165_chr_inversion_has_no_effect() { + let mut m = board(ExtBoard::M165, 32, 128); + m165_load_regs(&mut m); + m.cpu_write(0x8000, 0x80); + assert_eq!(chr_at(&mut m, 0x0000), 8); + assert_eq!(chr_at(&mut m, 0x1000), 16); +} + +/// PRG, mirroring, SRAM enable and IRQ are the MMC3's; the board has 8 KiB +/// of work RAM without being told. +#[test] +fn m165_prg_wram_mirroring_and_irq_are_mmc3() { + let mut m = board(ExtBoard::M165, 32, 128); + m165_load_regs(&mut m); + assert_eq!(prg_at(&mut m, 0x8000), 3); + assert_eq!(prg_at(&mut m, 0xE000), 31); + m.cpu_write(0xA000, 1); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + assert_eq!(m.sram().len(), 0x2000); + m.cpu_write(0xA001, 0x80); + m.cpu_write(0x7FFF, 0x77); + assert_eq!(m.cpu_read(0x7FFF), 0x77); + m.cpu_write(0xA001, 0x00); + assert!(m.cpu_read_unmapped(0x7FFF), "SRAM disabled by $A001"); + m.cpu_write(0xC000, 1); + m.cpu_write(0xC001, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + clock_a12(&mut m); + assert!(m.irq_pending()); +} + +/// A CHR read can switch banks, so the board says so, and the latches and +/// the CHR-RAM page are in the save state. +#[test] +fn m165_impure_reads_and_state_round_trip() { + let mut m = board(ExtBoard::M165, 32, 128); + assert!(!m.chr_reads_are_pure()); + assert!(board(M114_0, 32, 128).chr_reads_are_pure()); + m165_load_regs(&mut m); + m.ppu_read(0x0FE8); + m.ppu_read(0x1FE8); + m.cpu_write(0x8000, 1); + m.cpu_write(0x8001, 0x00); // R1 = page 0 (RAM), selected by latch 0 + m.ppu_write(0x0042, 0x99); + let blob = m.save_state(); + let mut n = board(ExtBoard::M165, 32, 128); + n.load_state(&blob).expect("same board"); + assert_eq!(n.save_state(), blob); + assert_eq!( + n.ppu_read(0x0042), + 0x99, + "latch 0 = $FE and the RAM restored" + ); + assert!( + board(ExtBoard::M197 { sub: 0 }, 32, 128) + .load_state(&blob) + .is_err() + ); +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 85bb63bd..bc41168f 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -144,7 +144,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // duplicate of 114 (the 114 page) and shares its model. A separate arm // so each release's batch keeps its own rationale beside its ids. #[allow(clippy::match_same_arms)] - 114 | 182 | 197 | 215 => Some(MapperTier::Curated), + 114 | 165 | 182 | 197 | 215 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -254,7 +254,7 @@ mod tests { // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, // v3.2.0 (`mmc3_boards_ext.rs`). - 114, 182, 197, 215, + 114, 165, 182, 197, 215, ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 2aac37d8..afd8c1d0 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1487,3 +1487,33 @@ fn extended_m197_super_fighter_iii() { "extended_m197_super_fighter_iii", ); } + +/// Fire Emblem translations hold the Waixing logo (drawn from the CHR-RAM +/// page) until START; a tap at frame 700 reaches the title and a second +/// tap starts the intro. Checkpoints: the title, then the intro. +const M165_TITLE_INTRO: InputScript = InputScript::DoubleStartTap { + idle_pre: 700, + gap: 400, + idle_post: 0, + free_run: 1100, + checkpoints: &[900, 1700, 2100], +}; + +#[test] +fn extended_m165_fire_emblem_ch() { + check( + "mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).zip", + M165_TITLE_INTRO, + "extended_m165_fire_emblem_ch", + ); +} + +/// Needs the board's work RAM: without it the title is garbage tiles. +#[test] +fn extended_m165_fire_emblem_gaiden_ch() { + check( + "mapper-165-FireEmblemCh/Fire Emblem Gaiden (Ch).zip", + M165_TITLE_INTRO, + "extended_m165_fire_emblem_gaiden_ch", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_ch.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_ch.snap new file mode 100644 index 00000000..1bb066b5 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_ch.snap @@ -0,0 +1,14 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).zip +rom_sha256=ecd0a059c21392e2c545e761151944157dcea3d93c72082803a54ec975001c17 +frames=2202 +fb_bytes=245760 +cycles=65546887 +audio_samples=1615058 +audio_fnv1a64=094c6eec1c70c8ec +checkpoint f900 fb_fnv1a64=359ad9f5331ea1b3 +checkpoint f1700 fb_fnv1a64=28331e0f69dc3576 +checkpoint f2100 fb_fnv1a64=4e8ecfc2344f015a diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_gaiden_ch.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_gaiden_ch.snap new file mode 100644 index 00000000..1669ebaa --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m165_fire_emblem_gaiden_ch.snap @@ -0,0 +1,14 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-165-FireEmblemCh/Fire Emblem Gaiden (Ch).zip +rom_sha256=1d7310754adc826d0fe6d35836c0b5550c5bc0a8d3ee6ab46f35c01d949f2e15 +frames=2202 +fb_bytes=245760 +cycles=65546882 +audio_samples=1615057 +audio_fnv1a64=4c73134de929f917 +checkpoint f900 fb_fnv1a64=50e790162f1399ec +checkpoint f1700 fb_fnv1a64=50e790162f1399ec +checkpoint f2100 fb_fnv1a64=50e790162f1399ec diff --git a/docs/compatibility.md b/docs/compatibility.md index 68c9d360..5b968a07 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -376,6 +376,8 @@ screenshot of that frame. The dumps themselves stay in the gitignored | 114 | Aladdin (SuperGame) (Mapper 114) [!] | `5489244538c94653f766bd80f07b2aa684ad139bddea68f40b60a0473c9286e2` | 600 | `screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png` | | 114.1 | Boogerman (Sugar Softec) (Unl) [!] (GoodNES labels it 215; staged with a NES 2.0 114.1 header) | `ba2d65f240d8f97c7b77973042d8141cf15116ffc3d73caed450c21e08fd54a2` | 600 | `screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png` | | 182 (as 114.0) | Super Donkey Kong (Unl) [!] | `67d06173c4b8239d46c84335591216b1adfa38344107405ec88c58327217a361` | 600 | `screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png` | +| 165 | Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch) (START at 700 and 1101; screenshot = the f2100 checkpoint, the intro text over the sword) | `0d396a7615b2499edd0997628b733236d88b002e364833edaf166e0a1c96ecf9` | 2202 | `screenshots/external/mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).png` | +| 165 | Fire Emblem Gaiden (Ch) (same script; it holds its title) | `621a4d4ee54524e9e62cce97adcba5b332c06bd1e4174a37455f474e747cd35e` | 2202 | `screenshots/external/mapper-165-FireEmblemCh/Fire Emblem Gaiden (Ch).png` | | 197 | Super Fighter III (Unl) [!] (submapper 0, PRG CRC32 `C333F621`) | `dfb4771a36dbcb05a2753a95810a11164665282ccac4cf7ee52d26d2ed6e5e4f` | 600 | `screenshots/external/mapper-197-SuperFighter3/Super Fighter III (Unl) [!].png` | | 215 | Mortal Kombat 3 Extra 60 (Unl) [!] (scramble pattern 4) | `19ccf8d3a3fc37b82d403d0668b00f05125bfcf01ad510dfff73e3ac30dad827` | 600 | `screenshots/external/mapper-215-SugarSoftec/Mortal Kombat 3 Extra 60 (Unl) [!].png` | | 215 | `2-in-1 Super 808 (Unl) [p1][!]` (scramble pattern 1, menu) | `4211468e61181f0956f10259ba6091ea3fead297e61ba7f22d7daa227463414a` | 600 | `screenshots/external/mapper-215-SugarSoftec/2-in-1 Super 808 (Unl) [p1][!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index 334e2562..36fc5f9a 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,7 @@ boot snapshot in `external_extended.rs` and a committed screenshot. |------|-----|--------------|------|-----|-------| | 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. GoodNES labels *Boogerman* (Sugar Softec) mapper 215, the assignment the 215 page says was withdrawn; it never writes `$5007` and boots only as 114.1, so its D23 record uses a copy with a NES 2.0 114.1 header (`Nes::rom_sha256` excludes the header, so the identity is the dump's). | | 182 | — | (`INES_Mapper_182` → `INES_Mapper_114`) | Curated | as 114 | A duplicate of 114: the 182 page is a redirect, and the 114 page says Hosenkan's 182 games write 0 to `$6000`/`$6001` once and never again. Dispatched as 114 submapper 0; the full 114 model is kept rather than the "no `$600x`" simplification, since the zero writes leave it in the MMC3's own banking. | +| 165 | — | MMC3 + MMC2 latch (`INES_Mapper_165`) | Curated | MMC3 (Sharp) | The page is Disch's notes only, so three readings are this model's. (1) A 4 KiB CHR page is the MMC3 register over 4 (`$0000`: R0 / R1, `$1000`: R2 / R4 by latch), page 0 being the 4 KiB CHR-RAM and page n reading ROM at n x 4 KiB; `$8000` bit 7 has no effect. (2) The latches are the MMC2's (`$0FD8` / `$0FE8` exactly; `$1FD8-$1FDF` / `$1FE8-$1FEF`), switching after the read. The notes' remark that the game reads "`$xFD0` or `$xFE0` via `$2007`" is not taken as a trigger: triggering on all of `$xFD0-$xFDF` drew a stray bar before every intro line on the Fire Emblem translation, the MMC2 triggers draw it clean. (3) 8 KiB of work RAM by default ("SRAM enabling ... as on your vanilla MMC3"): without it *Fire Emblem Gaiden (Ch)* draws its title as garbage. CHR reads are impure (`chr_reads_are_pure` is false). Curated on its D23 records under this release's rule; under v2.9.6's page-only rule a Disch-notes page would have been BestEffort. | | 197 | 0-3 | MMC3 with 2 / 4 KiB CHR banks (`INES_Mapper_197`) | Curated | MMC3 | Modelled as the page's WIRING, not its bank table: the MMC3 sees PA10 = PPU A11, PA11 = GND (sub 0, 3), Vcc (sub 1) or PPU A11 (sub 2), PA12 = PPU A12; its CHR outputs A10-A17 drive ROM A11-A18 and PPU A10 drives ROM A10. The page's tables for submappers 0-2 follow (unit-tested row by row), and `$8000` bit 7, which the page does not mention, still swaps the pattern tables as the MMC3 would. Submapper 3's `$6000-$7FFF` register (`S..P`: PRG A17 = P when S is set) is on the WRAM interface, so it is taken only while `$A001` allows a write. PRG, mirroring and IRQ are mapper 4's (Sharp). iNES 1.0 images are wired as submapper 0; the page's submapper 1 / 2 games are identified by PRG CRC32 there, which belongs in the per-game database, and no dump of either is staged. | | 215 | 0/1 | Sugar Softec UNL-8237 / UNL-8237A (`INES_Mapper_215`) | Curated | MMC3 (Sharp; the page names no revision) | `$5007` (mask `$F007`) selects one of eight scramble patterns (0, 2, 5, 6, 7 are the identity; 1, 3, 4 per the page's tables). `$5000` `MCS. BBBb` is 114's NROM override plus `C`, the 128 KiB mode that replaces PRG A17 / CHR A17 with `$5001` bits 4 / 5; `C` is applied after the NROM override, since the page describes it as a substitution on the A17 line both drive (the order is not stated). `$5001` is the outer bank (sub 0: PRG A18-A19 = bits 0-1, CHR A18-A19 = bits 2-3; sub 1: PRG A18/A19/A20 = bits 0/1/3, CHR A18-A20 = bits 1-3), power-up `$xF` (taken as `$0F`) and restored on a console reset; `$5000` and `$5007` power up `$00`. No WRAM unless a NES 2.0 header declares it. The page's index tables were checked black-box: pattern 1 (the *Super 808* menu, then *Aladdin* selected from it) and pattern 4 (*Mortal Kombat 3 Extra 60*) both run, and pattern 1's index table is the inverse of 114 submapper 0's, as printed. The "alternative outer bank registers in the `$6xxx` range" some multicarts carry are not modelled. | diff --git a/screenshots/external/mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).png b/screenshots/external/mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).png new file mode 100644 index 0000000000000000000000000000000000000000..cc544fafb40391e59fbfb833757ce4f872dcd2e4 GIT binary patch literal 3591 zcmai%XH-*J*T>IIZr~D-gh7hbL<9>$C>8{fQ00R%7*7N1=`#b-$&pqqhwa?oBi=O+o zFC*#^0RYQ(?r`%00K#6uIHH6-C7IPt*m%oMH|L*XZ;kcxub6JpYMiJJsdr7&7{{$L z(V*%UY8LEM@wpgFpwUa}l%9i-paN6MiHikyAr0!@7pmm63GynPxR~cr^X2}}9)@R* zO)c(toKuzjaC-vlSWUR-=7}Eh&B=%TCwhXN68en#97pei^+O5X#sK|B2IyfJFmxfH z>jA(yjsL|?iwz`zE-MG%B?1IiIAC=d05y~UkA1fmQRBs*^0GXHaidUR z2@F=?jCw9XN*%aaGD2XhvWpcCh7*c*Z**q>g_y{wS*wkzoM`8c_Y9 zrQj=8BVML?N`lDnKU$!?v78sKu zJDlH$_uDLRyl33l*|I#CRO-x2xGTKz^t0ziARWjtXC|4&G)Ueo)jrp%O87mDAk-3E z{gk2=v<8k~TcWJ7zaIn=E<_S6<#VN_wkD>2(LclO zPyxh@Tlf^kn8L5UBDy}kP^Ydnn%w5!x~Z@$&Jv1XNP z9ZAndpPZFwM1OE$`k1RC#uzslH%tJU``~qs*P8x}k(k0LA%c{vrzw04IU3P3L6#jj z^&6>M73yC$UQ8vUDiYNJqDbqvmKV2q!^w}_*-KIvy)AbvH<;6;iC-5>8|({Z#&d=R z0sSZM5h!o!YCX*bd7l@bqr9;OCa<%5p4aI0rW2nS06ME9d7#aG8*5J+4CUeXyupO(W>Eo98(aPxAYLIYr;(Rq;JqkwM@sv##S=Nw% zu0!cqHm<7)v`OG1#-2Ki;#tR6Fu+6Pb*$8X&k!8m1FQ@{+3zYP->f}K{eayQU#l10 zP*u0}v*{`pYk8IJ2kFXeo~%hURAg)ypIDW0T6}e|buRbTh=;#Zi60oXhT<+VC4I0( z{U)27wQx&OYVpLpZDZz-sEqTOUnogU>dWpgy<4aZxejebh3$~HpEy$S&)?bpmwOYo z_Yw{&_CHbtZ;OiQ)jNA&eoWPoCG(F@qUWx;caq4C9o zfi*Gyc}c3)xI=(21rPOJ{5(aSFM2g!DwO?N4um)6!0YVQWcw*&6^=>8-utR(27+G3 zs+Q${z4TMNSHmNx^MD?9uMwOQslTqP5u%LmrJbkxb$jPWsT*~+NvlnwI-`c`0-yFR z1spaDd{a77TcLai>*xW%1&8#fQ^#RbgXcMHQ`!n=5mCTJz+r#T#vW->_!9ozFsF05WQhUz(&eajl7m zK(?GP#B7kHZ0j)89kVeGzeDi!xi$HnW-R~bd@_&7cC;lSM-0osbzWiRsdIU>#Dfd_ z{+e|ige*fmRw$$XuwZ+cwp*0aYCc&$vv1Xk3a6%7CMxFN_i!M`tT3qHQlPl2 zLPTWFxL}uwA-{ZfZgFa6Tzq@T8$7Vq0oL&~wAJP7?V=s54dM6_Pu95rz3(^y6`uLy z`*yOgr9nLZG)ym;<#qM})3E8Fl$4{uj!XCX!kh6pbkfP*7D_8~XuV2i&TuFlpU0t< zcq}(yo#V@Ie-@Vp3IE;$(?dskZK?TACc%9AT%0{HaA7#K*UJ!e!!Wew_AeaECC`vq zV^L1pnWRETI4?Ihz|ojU_~s?oU~Y1H6|PFT??A5k>HZl?Tj=XAMzMK{nW?dfiHY{X za=5#fbxVNVhE=;y?G_E5uML)ji-}mT;GH~siG1O&r(f>5P3RLr;Pu;Oabp09gvg=8 z4&q0&*2&V6d{gw73=*1_?rz6(5mdkkBt2>dr0ciRMqU|~!zB+s7vn|`%su>NpsFk} zB)>KC@tIpH&VGjU6HUtK`q?`TfnmL@2&7UBsHjnf1ULJs*L*2mE}J&e9Qc8H$VLMc z+9vM=ZA4D`Lp&E8I=Bt1TZOR-yUdxs8+e-l8yC8mK)F`>orgIJt!9xF;)Vvg>9&Xe16NQdj4ixh-@CpQyFcq znI7h%dBy_)O)Q+<%X(e3VDsz;UsfX0L!*!&az`uFCLg)}&(% zp!P|X*^wjHE*Xd&7&=f%EV6?OHdKR@807+98lx%lCW5+?adsTwgHTZ~Sa1Y^u2X=v z8$!|XhO~#3XS}?TN_&Qm1$y>#K>x_s|0#h_qv{M2#K2Be7biem02M~5*s3^zt_%19 zVvwmIH4dD!-Ve%w8g>>47#vma^Z+Os>p}&aLsUB5z<>i6b%a3x1rDczaUzwWJO#)N zTiMg^aP;L!+|b@JxvB{T#!d+0o~e#Vc3ip9B$Uk zGPVGI_WOZoC1Cy9fHiVH&x29~af zUn&sXMc82)pwfWKG{gZ{3S8ujwc=cQG7QeSAX0b0z-rJ512;s9DL!0!0tOyPIi|#N z=?*ZsM@PyrMUP8Q!oUS-!xVci9XpzPZb%!Z9O4S%Fz`TbxdKK!R}cw<3_45~F90jg7sct&WZa<7AfN`C}BIzmi_LVj+%n4hvvYjRk}`2?HGF zH|$`{S1=LGeS9QSFk3;T1F+haaU2H1HSqBe!v7KeovlVv>w9*hCNPOCxSZ>h+unOu z@PW?!Xq~A?r-Q&IN?96^!zS*7G`Ry;pP+tqm$j1l(08~d?-3S0cXDYD8 z06CMJ>0i14fOlL+UDd=tYyG`S+~tm%cI$J&G*|c;nORaM`t)g|m{_h$Wju9ue-c3q z!@4bWR^@eXXt2;iCe_^$KNxK3ArZEm65ju8RA=1ZoHENSPAZhX=*c#IIBEINa&rH~ z=Iy(>#?qDbjorZ#W9n3ch19eKgRt=qX^KuLa*du2lN9p-V3WDQ1>C(H1x9v$GXxmk z@h}3yoN6GUr%%Lz$otIy?;dq-b<0oh1#m>J0dzPZ7EqAj!Z2G0ZGYdF*T7`4Ga{o} zPWItkAWziPpGF{W_Z9WDZM8+^WUH<8%GR*C+dAsl*b?g}kG z5Lm!~-EG*lc|%IJ%?G!J;&5zP{EWm_btJYqtrcxZyATF;XJaS&66PKb%!slRm6p$C zaVF4~VR*C>*&~d0FNB!!t=UMN$ZvT z?psoK`Qwd~tEx#1N+wI49v=Ya>tOqnqdEjdnOV>_{cn{mv(3y~O!&V}7f`zFsCs|9dN&bW8t8igj)d44JdAs$!d$hSI>&S2X z3HKA|=xmVe;s}cPjNCTNr+W&agG|wRB>m#`E;<=<< zB#1|wHW!(Nu)j!y%Q3Gie%Nlp8MZ-HzyZu6U>Q6tad{@kgq-7tH$Bd)DrBzn=96YT zzX%rK*N5=ggOhoRzEB?wDtm4k<6F+N+(2#L4mq7}XkT^htX)n|bN*&zre9z;5i5b;39^}ne z!Pnvvg)>iFb_&V3OcT-Z3M|dwc>lKH749Fs(AiWwBV9XqM06Za1z)JF$b*;2pW)!} z<-UVn5S@*5zT9v!T5K{P`2{XI8EqBL*CIbZ3L))z9hR?$c(RBC5w>}hAXu7Q1FEh3BBF4t9z&_&Pp{t^X$(W|`i zcJHb(;cdw6+qUJjF?2ScT+P&q7`7#d9YeE_D7`017Q zm2xPX-bq!?_}{Nt%kr$wU;%fM&7eRr;hh$j|I=}*k|Dr?xmZvn!lt+PgEusWVbrT$ z_CVomq!Z-*>6JlWR-ZC)VZx$^=Oy0B_i0sY_IQZX0*DDO0RJKZKzkp02Y5kVY$ObkOR^0(w_S~#9G$gaiSjSh-x-~3w+ z5#VhJnx4Sq4|*Ji+=jl}dkG`zw98yIBBo(ZC z3*@C)xG)Lot6N3Pg3$YQEW5uoyqA2FMXennYui|aw@NKsev*p`IEHz|g+V{Hupe@s;K7T%u$TMN?iMtzYOnyIYnS>_wE0r;AaCUHe3cW6 zQ!qmOXPcf(%i_h+ z&2o{KA~-#eIjm7lri3*DBN4+qc9X&&TU=ms$Npp`AO+H2mK?ZzjW3H+wB4y`vxGCS zZ2QSgaT#|wMtckiD()QX-2aW0U$+1W*InWc_hPOl4TbotCIsZ>FWO{Uc3sa|byny9 zOJ;uD1UZ@5wb4fN(3pk2C>}m&605oQXrLQ$e(mFJU1q_qg5=Qq2G9w}b;4%xddMlW z-NhpXxd)!+*{RMknk#CcV&ZEA(7#^yBq{UcpJYkRw}bKK2U7jLF$bD1g*IW&U;1!A;xv*O_e3o*2Rx;2$qd=aHMb1%Xc}^d> zsuW6eR5_E}ELo)yR{(li9R&|z_S0VwsOZuWIZO`&onerZEwuQIt1jFHza&w`E5yfg z#?y&iU5wFqdR!FpI8>Vu_wCN)816As~*Ra zPU#nSo8s-ch2A^aI0Z%4gsR-)wwXN$RlH^}G4=brSJRmFmFb!7;`ZtYA5VTC&GtlP zV5-uh;+OAV4g=b<@rser7_~rUODd(0rnry7?b%*2lH&a3@mP)h?q_44^x>0HD9_N> zsmO~L2WHlt*swg+?TH=QDx*w>$rE#e3ix(Qr@>5~EQ1Iz(^_D}5E?`6}7j*~GO!&?N@)YA*p}XQILf0QKBl5+XZgoD%A-Tn` zDP263Z;;n<>_NbbM{47FI+b%@YUcTC(oiUTKbG=*Ye$ct_FM4EbR%^OJAFjnwQgz{ z^np5OPfgbnM;-XFwgtK} z6H-Eh4i{H%rcT$vRq6$>0}RO63vf0*ugKu5)n$^_X@jRkRQGX{-@5hA+Bb2u?78d; zdyd>~2`$dBHhPd(%{_@Z)#>2Fd$eV$rME2T{tdXPa0U)>d0HEhk=9db7P~E>y9xpG zcRjv-s)^QF`fRwz>zI91m!^|bbi4-3-h80YM9)^G4xj=V(6vl?888*Hm7_v)%|vpY zT^HB9TbBj@`3we$rLR-HpXrJ?Ft7P~z0~sTB)b-d`f7ITy1C5r0uFO+tG7&=np~Xo zUjg=yx6r6wVn~QAIVAQn^65csuy3$`LSrF3E9(kt;HsJwbT0s5>tZC;yzpw#jNJGV zEkPzL3@V6Kr97I*coFO}%*%dDagugs>bTNLA3h^wg&N?5>{1+WUBzLs|1YY-P={ zpoVOBbj_1Wxfj_01ce-W_-~(T5L&B8D_tCgPzu@L4P_~((}f+qzD2_)%=c$Ch;Ohq z+gA9f;Yx_|Sfjr|*{kPtj;z^!j+CYCu5~2M+$PbgF$t*|0%z@Y$#yS)(#)T3+eL8n`1ExbyOXIdOV9UY zKgJtoC&}r;K9Ye~b~tn>R*Fr&(pZVb zbqqCvk7B?{tRoaD2!~M{cKx$6!a%J7 z8W`2I^BBH!`y;!i!HQc$onLzItExoXN3^fu1=|={drqmI<50-TGaUM@%r$3NzGDhs z^mg2balS{`7WtCwMA$%z7kOW5>jGW8?dabr&-)t)Usbrq_$eViwC)G;-@;Nck#DAZ zm>WiQeb}bKs_@gGXGBJ$MR(xZU((JSEr&d&?5?`!3S3-2#3_A_9?;Fllb0Q-6K0%f z0%7FAk@EVJ(H}_3FY3Di9UR8mFIH|odpfXdamfYj9Zv>4F!G3 z9Wy?zBz#gI!8=@$Y8}3JRkWjbZMtH>+(WR2d1~MP{)1Tu4>v;-m!mrO3Zq{5;)nbl zGa2iJK`^d0q5LM;SaU8XREVQ(w??X}FHqJn25Kak4fR7*PIwTWSpzP`hHoY-Ri#0o+LGmUKYcSZFF+3fh! zu>TL^uh-SvpML*R>yCo!KbTG2aH?38@b$dN+UI|H##I&R;WrFXT4PF4>=0dqmPsZS z66Dco!Y}q3ytdyG@a=?JhoFrnHNca3Kh)Of5PdyLOCVEbq)S95U{SLn;_7b8^*&byrC@%Q$3S$}=KIQ9(* zcgdtRRXniEIi#vdGBtN}7AZSezDcs%an@42HMv2IOrr4zY9KW1p5ECTvJCd7tjHo| z4jIMGkkgMc6A^XfkEaFWcC6n;pUS$n1bWoGvSJF`kOIEUv6pnDrFU3m9=Dh_JpZSW zC$<~42H~n1xM=wN!ysEATEj@Ho0CeSz;#OZyaGerrV?KW)=bmSq+~|G!a6&L`NpI3 zzs)jFZ^jz}x4kAWl!trF;jb#-=eK`GwdCy*_Kb4=P_Hr^du5ytOQ7XTqqJ!8rgV;O zPh{cx-X=P5PtwiY-Z*@fzTxsd*?wqPN*VUx$&$jookFt%3pBWS10DK>iI_c`O%_=^ zLM0LvJD$*8@=-FCJNq|M2?G(~g*6!nESw~uNv!B%Ply1Dr z5wnKv)f#Nm{_eYmD;QPct7NnBQCIhS24whrfU^VRAPERm&f)z4c+1|-G{E9-tZEFp zwpHkSuB9iwRaF!R=nIG5hD&;2E{JI9Ujlv)wWdE&>{qr}{i+2*DoP3Yoc^}sijvw{dYa)S`kBZQ*n){$)60$()@1U zOO%+N4tiDEqL6y|2*1jLM3Y`K{{=A^joY*3SIapOvX6%MwBXYW0l!{TF8V(r1?T*x zL6WVVq)*LFb%ug}qdp-{`@2SNp9%T9N4<9f_V1L|J)icgiXa4j_vL5I%D52pP>ol7 z?@wv#mEJptzE!l&Z(==NA0C+U%is+{SNF@iOkan{ZmEwKWw+W)*sc>(AO8-e?yog- zx80|KJ@M)*fa6_cgq&!+?G69ye+_##1o;h$m+>;E9<}u7#pvJy-y6&UkLTb1Dv*j1 z?Cd5DhB_VPrNdL3UGgZaZki^rrWEVXBiLxTXxxGch&{=G+Hf$E z#o(hRHlc)ly2AU9t_H5Q-|ak{i4Kzm?iClYYwdNL@$ad3a1-<3bMpFF^Vg7U&Y>mJrr5m8 zJe?uXnQ{YTfg&?aZf14mTr)^XlQdwu#exyYE Date: Thu, 8 Oct 2026 21:46:34 -0400 Subject: [PATCH 016/104] feat(frontend): scan a Datach barcode from the Emulation menu The desktop shell gains a way to swipe a card through the Datach reader (mapper 157), refused with a visible reason whenever a movie or netplay owns the timeline. UI (ui_shell.rs) - Emulation menu: "Scan Barcode..." (FA `barcode` glyph, present in the embedded font), shown only when ShellFrame::barcode_reader is set (Nes::has_barcode_reader, captured under the existing brief lock before the egui pass). Greyed under a movie or netplay, the reason on hover. - "Datach Barcode Reader" window: a digits field (kept between openings), a live line naming what is wrong with the input (empty, a non-digit with its 1-based position, not 13 or 8 digits, a wrong check digit and the one expected; the same Barcode::parse the core applies), the lock reason when a movie/netplay runs, and Scan (or Enter), enabled only for a valid code with no lock. It dispatches MenuAction::ScanBarcode; the shell never takes the emu lock. - ui_shell::barcode_error_text renders a BarcodeError in the current locale. Dispatch (app.rs) - MenuAction::ScanBarcode asks session_policy first (TimelineAction::BarcodeScan), then App::scan_barcode locks the emu, calls Nes::scan_barcode and puts "Scanned barcode ..." or the core's refusal on the status line. The source-scanning guard test (every_timeline_action_route_asks_the_session_policy) lists the arm. Policy (session_policy.rs): BarcodeScan is refused under a movie (recording or playback) and netplay, allowed in RA hardcore (swiping a card is ordinary play, like a disk swap). Movies and netplay do not carry scans, so refusing is what keeps them from desynchronising silently; recording scans is future work. The libretro core, mobile apps and Lua expose no scan. i18n: ten keys, English and Spanish (machine-drafted, like the other v2.9.7+ Spanish strings). Docs: docs/frontend.md (Emulation menu, session lockout) and the mapper-157 movies paragraph in docs/mappers.md. Verified: cargo clippy -p rustynes-frontend --all-targets with default features, --features full, --features retroachievements, and both wasm32 invocations (default and --no-default-features --features wasm-canvas), all clean; frontend lib tests 686 passed (new: every_barcode_refusal_names_its_reason, a_barcode_scan_names_itself_ when_refused, the policy tables extended to BarcodeScan). Mutation: replacing the dispatch guard with `if true` fails the guard test ("`MenuAction::ScanBarcode(code) =>` runs without `self.refuse_timeline_action(TimelineAction::BarcodeScan)`"); restored. Not verified: the window was not opened in a running desktop session (no interactive GUI check was made); its behaviour is covered by the unit tests and the dispatch path above. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-frontend/src/app.rs | 39 ++++++ crates/rustynes-frontend/src/i18n.rs | 10 ++ crates/rustynes-frontend/src/icons.rs | 2 + .../rustynes-frontend/src/session_policy.rs | 25 +++- crates/rustynes-frontend/src/ui_shell.rs | 119 ++++++++++++++++++ docs/frontend.md | 22 +++- docs/mappers.md | 4 +- 7 files changed, 213 insertions(+), 8 deletions(-) diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index c60c1cfc..7f73efc2 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -2687,6 +2687,28 @@ impl App { /// v1.8.9 — insert a specific FDS disk side (`Some(i)`) or eject (`None`) — the /// Multi-Disk submenu's direct counterpart to [`Self::cycle_disk_side`] (a /// multi-disk game prompts "insert side N"). An out-of-range side is ignored. + /// v3.2.0 — swipe `code` through the Datach barcode reader and report the + /// outcome on the status line. The core validates the digits again at its + /// boundary; a refusal there (malformed code, no reader) is shown, never + /// silently dropped. Callers have already asked `session_policy`. + fn scan_barcode(&mut self, code: &str) { + let result = { + let mut guard = self.emu.lock(); + let Some(nes) = guard.nes.as_mut() else { + return; + }; + nes.scan_barcode(code) + }; + let message = match result { + Ok(()) => StatusMessage::info(crate::tf!(ShellBarcodeScanned, code)), + Err(rustynes_core::BarcodeScanError::Malformed(e)) => { + StatusMessage::error(crate::ui_shell::barcode_error_text(e)) + } + Err(e) => StatusMessage::error(e.to_string()), + }; + self.ui.set_status(message); + } + fn set_disk_side(&self, side: Option) { #[cfg(not(target_arch = "wasm32"))] self.flush_fds_save(true); @@ -5465,6 +5487,14 @@ impl App { self.set_disk_side(side); } } + MenuAction::ScanBarcode(code) => { + // v3.2.0 — a Datach scan is not in a movie's or netplay's + // input stream, so it is refused (with the reason) while + // either owns the timeline, like a disk swap. + if !self.refuse_timeline_action(TimelineAction::BarcodeScan) { + self.scan_barcode(&code); + } + } MenuAction::Screenshot => { #[cfg(not(target_arch = "wasm32"))] self.take_screenshot(); @@ -10678,6 +10708,8 @@ impl ApplicationHandler for App { disk_sides, inserted_disk_side, vs_system, + // v3.2.0 — a Datach cart (shows "Scan Barcode..."). + barcode_reader, mapper_label, region_label, movie_recording, @@ -10701,6 +10733,7 @@ impl ApplicationHandler for App { 0usize, None, false, + false, String::new(), String::new(), rec, @@ -10720,6 +10753,7 @@ impl ApplicationHandler for App { nes.disk_side_count(), nes.inserted_disk_side(), nes.is_vs_system(), + nes.has_barcode_reader(), mapper_name, region.to_string(), rec, @@ -10741,6 +10775,7 @@ impl ApplicationHandler for App { disk_sides, inserted_disk_side, vs_system, + barcode_reader, mapper_label: &mapper_label, region_label: ®ion_label, run_ahead, @@ -13107,6 +13142,10 @@ mod tests { "MenuAction::SetDiskSide(side) =>", "TimelineAction::DiskSwap", ), + ( + "MenuAction::ScanBarcode(code) =>", + "TimelineAction::BarcodeScan", + ), ("MenuAction::LoadState =>", "TimelineAction::LoadState"), ( "MenuAction::LoadStateSlot(slot) =>", diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs index 58a53919..2ace156b 100644 --- a/crates/rustynes-frontend/src/i18n.rs +++ b/crates/rustynes-frontend/src/i18n.rs @@ -598,6 +598,16 @@ catalog! { ShellSpeedPct => "Speed: {0}%", Some("Velocidad: {0}%"); ShellRegion => "Region: {0}", Some("Región: {0}"); ShellSideN => "Side {0}", Some("Lado {0}"); + ShellScanBarcode => "Scan Barcode...", Some("Escanear código de barras..."); + ShellBarcodeTitle => "Datach Barcode Reader", Some("Lector de códigos de barras Datach"); + ShellBarcodePrompt => "Type the 13- or 8-digit number printed under the card's barcode, then press Scan.", Some("Escribe el número de 13 u 8 dígitos impreso bajo el código de barras de la tarjeta y pulsa Escanear."); + ShellBarcodeScan => "Scan", Some("Escanear"); + ShellBarcodeLocked => "Scanning is disabled while a movie records or plays, or during netplay: neither records a scan.", Some("El escaneo está desactivado mientras se graba o reproduce una película, o durante el juego en red: ninguno registra el escaneo."); + ShellBarcodeNeedDigits => "Enter 13 or 8 digits.", Some("Introduce 13 u 8 dígitos."); + ShellBarcodeNotDigit => "Character {0} ('{1}') is not a digit.", Some("El carácter {0} ('{1}') no es un dígito."); + ShellBarcodeWrongLength => "{0} digits: a JAN/EAN barcode has 13 or 8.", Some("{0} dígitos: un código JAN/EAN tiene 13 u 8."); + ShellBarcodeBadCheck => "The check digit is {0}; these digits need {1}.", Some("El dígito de control es {0}; estos dígitos necesitan {1}."); + ShellBarcodeScanned => "Scanned barcode {0}", Some("Código de barras escaneado: {0}"); ShellShowLagFramesHover => "Show a counter of frames where the game polled no controller (a TAS / debug diagnostic).", Some("Muestra un contador de fotogramas en los que el juego no leyó ningún control (un diagnóstico de TAS / depuración)."); ShellLagCount => "Lag: {0}", Some("Retraso: {0}"); ShellLagHover => "Lag frames since ROM load (no controller polled). Toggle in View -> Show Lag Frames.", Some("Fotogramas de retraso desde que se cargó la ROM (sin leer ningún control). Actívalo en Ver -> Mostrar fotogramas de retraso."); diff --git a/crates/rustynes-frontend/src/icons.rs b/crates/rustynes-frontend/src/icons.rs index c3da33fe..0bf4a91d 100644 --- a/crates/rustynes-frontend/src/icons.rs +++ b/crates/rustynes-frontend/src/icons.rs @@ -56,6 +56,8 @@ pub mod glyph { pub const CLOCK_ROTATE_LEFT: char = '\u{f1da}'; /// `floppy-disk` — save state / save-slot actions. pub const FLOPPY_DISK: char = '\u{f0c7}'; + /// `barcode` — v3.2.0, the Datach barcode reader's "Scan Barcode..." item. + pub const BARCODE: char = '\u{f02a}'; /// `download` — load state / import. pub const DOWNLOAD: char = '\u{f019}'; /// `camera`-like image; FA Solid `image` glyph for screenshots. diff --git a/crates/rustynes-frontend/src/session_policy.rs b/crates/rustynes-frontend/src/session_policy.rs index 553fbf95..ae722068 100644 --- a/crates/rustynes-frontend/src/session_policy.rs +++ b/crates/rustynes-frontend/src/session_policy.rs @@ -30,12 +30,15 @@ //! | --- | --- | --- | --- | //! | Reset, Power Cycle | refused | refused | allowed | //! | disk swap / insert / eject | refused | refused | allowed | +//! | Datach barcode scan | refused | refused | allowed | //! | state load | refused | refused | refused | //! //! Reset and Power Cycle stay legal in hardcore because rcheevos treats them //! as legitimate (they are what a real console offers); a state load is the //! one thing hardcore exists to forbid. A disk swap is an ordinary act of -//! play on a real Famicom, so hardcore allows it too. +//! play on a real Famicom, so hardcore allows it too, and so is swiping a +//! card through a Datach (v3.2.0): a scan changes the machine through the +//! cartridge's barcode reader, which neither a movie nor netplay carries. //! //! When more than one owner refuses, the hardcore reason is reported first //! (it is the one the player cannot end on their own), then the movie, then @@ -53,6 +56,8 @@ pub enum TimelineAction { PowerCycle, /// Any FDS disk change: cycle sides, insert a specific side, or eject. DiskSwap, + /// v3.2.0 — a barcode swiped through the Datach reader (mapper 157). + BarcodeScan, /// Restoring a save state (slot, manager, browser grid, or a script). LoadState, } @@ -65,6 +70,7 @@ impl TimelineAction { Self::Reset => "Reset", Self::PowerCycle => "Power Cycle", Self::DiskSwap => "Disk swap", + Self::BarcodeScan => "Barcode scan", Self::LoadState => "Load state", } } @@ -129,10 +135,11 @@ pub const fn refusal(action: TimelineAction, owners: SessionOwners) -> Option), + /// v3.2.0 — swipe this barcode (the digits as typed) through the Datach + /// reader (mapper 157). Validated again at the core boundary. + ScanBarcode(String), /// v1.0.0 — capture a screenshot of the current framebuffer (native). Screenshot, /// v1.0.0 — copy the current framebuffer to the system clipboard (native; @@ -293,6 +296,11 @@ pub struct UiShell { /// Toggled by the freed backtick (`` ` ``) key, which formerly toggled the /// (now-removed) debugger toolbar HUD. pub ra_detail: bool, + /// v3.2.0 — whether the Datach barcode window is open. + pub show_barcode: bool, + /// v3.2.0 — the digits typed into the Datach barcode window. Kept between + /// openings so the same card can be swiped again. + pub barcode_input: String, } /// v1.5.0 "Lens" Workstream I9 — selects which device the Keyboard Shortcuts @@ -366,6 +374,8 @@ impl UiShell { active_slot: 0, shortcuts_device: ShortcutsDevice::default(), ra_detail: false, + show_barcode: false, + barcode_input: String::new(), } } @@ -412,6 +422,9 @@ pub struct ShellFrame<'a> { /// v1.0.0 — whether the loaded game is a Vs. System title (enables the /// "Insert Coin" item). pub vs_system: bool, + /// v3.2.0 — whether the cartridge has a barcode reader (the Datach, + /// mapper 157). Shows the Emulation menu's "Scan Barcode..." item. + pub barcode_reader: bool, /// v1.0.0 — the human-readable mapper name (empty when unavailable). pub mapper_label: &'a str, /// v1.0.0 — the region label (`"NTSC"` / `"PAL"` / `"Dendy"`). @@ -495,6 +508,7 @@ impl UiShell { self.welcome_modal(&ctx, config); about_window(&ctx, &mut self.show_about); self.shortcuts_window(&ctx, config); + self.barcode_window(&ctx, frame, &mut out); #[cfg(not(target_arch = "wasm32"))] crate::about_fx::render(&ctx); @@ -1043,6 +1057,24 @@ impl UiShell { }); }); } + // v3.2.0 — the Datach barcode reader (mapper 157 only). + // Locked like the disk controls: a scan changes the + // machine outside the controller input a movie or a + // netplay session carries. The window says why. + if frame.barcode_reader { + ui.separator(); + if ui + .add_enabled( + disk_interactive, + egui::Button::new(ic(glyph::BARCODE, crate::t!(ShellScanBarcode))), + ) + .on_disabled_hover_text(crate::t!(ShellBarcodeLocked)) + .clicked() + { + self.show_barcode = true; + ui.close(); + } + } }); // ----- View ----- @@ -2440,6 +2472,78 @@ fn about_window(ctx: &egui::Context, open: &mut bool) { crate::about_fx::pump(ctx); } +/// The player-facing reason a typed barcode is not a valid JAN / EAN code, +/// in the current locale (v3.2.0). +#[must_use] +pub fn barcode_error_text(err: rustynes_core::BarcodeError) -> String { + use rustynes_core::BarcodeError as E; + match err { + E::Empty => crate::t!(ShellBarcodeNeedDigits).to_string(), + E::NotADigit { index, found } => crate::tf!(ShellBarcodeNotDigit, index + 1, found), + E::WrongLength(n) => crate::tf!(ShellBarcodeWrongLength, n), + E::BadCheckDigit { expected, found } => crate::tf!(ShellBarcodeBadCheck, found, expected), + // `BarcodeError` is not `#[non_exhaustive]`; this arm is unreachable + // today and keeps the text honest if a variant is added. + #[allow(unreachable_patterns)] + other => other.to_string(), + } +} + +impl UiShell { + /// v3.2.0 — the Datach barcode window: a digits field, a live validity + /// line (the same check the core applies at its boundary), and a Scan + /// button that dispatches [`MenuAction::ScanBarcode`]. Shown only while a + /// cartridge with a reader is loaded. Under a movie or netplay the field + /// stays editable but Scan is disabled, with the reason shown: a scan is + /// recorded by neither, so it would silently desynchronise them. + fn barcode_window( + &mut self, + ctx: &egui::Context, + frame: &ShellFrame<'_>, + out: &mut ShellOutput, + ) { + if !self.show_barcode || !frame.barcode_reader { + return; + } + let locked = frame.movie_recording || frame.movie_playing || frame.netplay_active; + let mut open = self.show_barcode; + let input = &mut self.barcode_input; + egui::Window::new(crate::t!(ShellBarcodeTitle)) + .open(&mut open) + .resizable(false) + .collapsible(false) + .show(ctx, |ui| { + ui.label(crate::t!(ShellBarcodePrompt)); + let field = ui.add( + egui::TextEdit::singleline(input) + .hint_text("4901234567894") + .char_limit(20), + ); + let parsed = rustynes_core::Barcode::parse(input); + // Say why Scan is disabled once something has been typed. + if let Err(e) = parsed + && !input.trim().is_empty() + { + ui.colored_label(ui.visuals().warn_fg_color, barcode_error_text(e)); + } + if locked { + ui.colored_label(ui.visuals().warn_fg_color, crate::t!(ShellBarcodeLocked)); + } + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let scan = ui + .add_enabled( + parsed.is_ok() && !locked, + egui::Button::new(crate::t!(ShellBarcodeScan)), + ) + .clicked(); + if (scan || enter) && parsed.is_ok() && !locked { + out.action = Some(MenuAction::ScanBarcode(input.trim().to_string())); + } + }); + self.show_barcode = open; + } +} + /// Render the keyboard-shortcuts window. impl UiShell { /// v1.5.0 "Lens" Workstream I9 — render the Keyboard Shortcuts window from @@ -2703,6 +2807,21 @@ mod tests { assert!(!shell.show_welcome); } + /// v3.2.0 — each way a typed barcode can be wrong has its own visible + /// reason (English, the default locale), with the 1-based character + /// position and the check digit the other digits need. + #[test] + fn every_barcode_refusal_names_its_reason() { + let text = |s: &str| barcode_error_text(rustynes_core::Barcode::parse(s).unwrap_err()); + assert_eq!(text(""), "Enter 13 or 8 digits."); + assert_eq!(text("12a"), "Character 3 ('a') is not a digit."); + assert_eq!(text("1234567"), "7 digits: a JAN/EAN barcode has 13 or 8."); + assert_eq!( + text("4006381333932"), + "The check digit is 2; these digits need 1." + ); + } + #[test] fn status_message_expires() { let msg = StatusMessage::new("x", egui::Color32::WHITE, Duration::from_millis(0)); diff --git a/docs/frontend.md b/docs/frontend.md index ecda55cf..5ed5ebd1 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -1084,8 +1084,15 @@ reorganized the order and regrouped several items — see the per-menu notes): - **Emulation** — Pause/Resume (disabled during netplay), Reset, Power Cycle, Frame Advance (`\`, single-steps one frame while paused), a hold-`Tab` Fast Forward hint, Run-Ahead selector (0-3), Speed presets, Region (read-only - display), Vs. Insert Coin (`F10`, Vs. games only), and FDS Swap Disk Side - (`F9`, FDS games only; moved here from File in v1.3.0). + display), Vs. Insert Coin (`F10`, Vs. games only), FDS Swap Disk Side + (`F9`, FDS games only; moved here from File in v1.3.0), and **Scan + Barcode...** (v3.2.0, Datach / mapper 157 carts only), which opens the + Datach Barcode Reader window: a digits field, a live line naming what is + wrong with what was typed (empty, a non-digit, not 13 or 8 digits, a wrong + check digit and the one expected; the same checks `Nes::scan_barcode` + applies), and a Scan button (or Enter) that dispatches + `MenuAction::ScanBarcode`. The outcome ("Scanned barcode ...", or the + core's refusal) goes to the status line. - **View** — Settings, Theme (Light/Dark/System + the v1.5.0 accessibility themes High Contrast / Colorblind-Safe), 8:7 Pixel Aspect, Hide Overscan, Fullscreen (`F11`, native), Window Size (1x-4x of the NES resolution, native), @@ -1125,9 +1132,14 @@ reorganized the order and regrouped several items — see the per-menu notes): **Session lockout (v2.9.9, NF-11).** A movie and a netplay session both carry controller input only, so anything that changes the machine outside that -stream breaks them: a Reset, a Power Cycle, an FDS disk change and a state load -are refused while a movie records or plays, or a netplay session runs, and a -state load is also refused in RA hardcore. The rule is one pure function, +stream breaks them: a Reset, a Power Cycle, an FDS disk change, a Datach +barcode scan (v3.2.0) and a state load are refused while a movie records or +plays, or a netplay session runs, and a state load is also refused in RA +hardcore. For the scan the menu item is greyed with the reason on hover, the +window keeps the field editable but disables Scan and says why, and the +dispatch refuses with "Barcode scan disabled during movie / netplay"; a scan is +not recorded in `.rnm` movies or carried by netplay, so allowing it would +desynchronise them silently. The rule is one pure function, `session_policy::refusal`, and every route asks it — the menu (which greys the items), the hotkeys, the Save-States manager, the browser grid; a script's load honours the `writes_locked` gate its `SetInput` already did. The F8 movie diff --git a/docs/mappers.md b/docs/mappers.md index 0eefa1e1..328c2e0d 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -918,7 +918,9 @@ before this change is different. **Movies and netplay.** A scan is an input that changes emulation and is not recorded in `.rnm` movies or carried by netplay. A host must refuse a scan while a movie records or plays or a netplay session runs, rather than let it -desynchronise. Recording scans is future work. +desynchronise; the desktop does (`session_policy`'s `BarcodeScan`, the reason +shown in the menu, the window and the status line). The libretro core, the +mobile apps and Lua expose no scan yet. Recording scans is future work. **Open bus.** The six undriven bits of a `$6000-$7FFF` read are open bus on the board and read 0 here, as on mapper 16: the bus consults a partial From 1da22ed2f2c0aa7fa0650fb900acd3c519a768b3 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 22:06:37 -0400 Subject: [PATCH 017/104] test(harness): boot ten "blank" dumps as a Famicom; KNOWN_BLANK 52 -> 41 Ten of the coverage sweep's 52 known-blank dumps were never broken. They write $2000/$2001/$2005/$2006 inside the window after power-on in which a front-loading NES PPU ignores those registers (~29,658 CPU cycles, NESdev "PPU power up state"). The Famicom does not hold its PPU in reset, so on one the writes land. With ConsoleModel::Famicom all ten draw their titles or attract screens: 015 Bio Hazard (Unl), Xiao Au Jiang Wu (Ch) 139 Final Combat (Sachen-JAP) 162 Xi You Ji Hou Zhuan (Ch) 164 Digital Dragon (Ch) 185 Sansuu 1, 2 and 3 Nen - Keisan Game (Japan) 227 Biohazard (China) (En), Xiao Ao Jiang Wu (Ch) (Wxn) All are Famicom- or Chinese-market carts. They were found by booting every KNOWN_BLANK entry under both models with the game database applied. The harness now has run_capture_model(rom, script, model), which sets the model and power-cycles so the warm-up counter starts from the model's own state; run_capture_opt is that with ConsoleModel::Nes. external_coverage gets a FAMICOM_WIRED list, boots those dumps on the Famicom model, and labels their snapshots "console: Famicom (FAMICOM_WIRED)". The list is a ratchet in the same spirit as KNOWN_BLANK. Each entry is also booted on the NES model first, and that boot must still be blank, or the sweep fails with "in FAMICOM_WIRED but also draws on the NES model". The NES boot runs first so the frames RUSTYNES_DUMP_FRAMES writes for review are the Famicom boot's; the first bless ran it second and every dumped PNG was the blank NES frame. The ratchet earned its place on its first run. Subor V1.0 (R), an eleventh dump the probe had flagged, was refused: it draws its menu on the NES model too. It was never Famicom-dependent. It draws its educational menu by f100 and blanks it at about f375 with no keyboard attached, so the default START-tap script (checkpoints f900/f1100) only ever saw black. It gets a 350-frame Presses capture with no input instead, on the NES model. Mutation: run_capture_model mutated to keep ConsoleModel::Nes for a Famicom request. The filtered sweep fails "10 of 11 staged ROM(s) failed their boot coverage check" -- exactly the ten, with Subor still passing. Restored from a snapshot copy. Also fixes clippy::match_same_arms in InputScript::total_frames, which the Presses variant (3621db66) introduced. common::external is gated behind the commercial-roms feature, so the default-feature workspace clippy never compiled it; only cargo clippy -p rustynes-test-harness --all-targets \ --features test-roms,commercial-roms -- -D warnings does, and it was not run on that commit. Verified: cargo fmt --all --check; that clippy and workspace clippy, both clean; the filtered external_coverage run, 6 passed / 0 failed with the eleven dumps; every frame viewed (ten title screens and the Subor menu). Not done, a maintainer decision: the frontend does not pick the Famicom model from the ROM, per ConsoleModel's stated design (NES 2.0 cannot name the console, and the project does not guess one from a per-game list). A player must choose Famicom in Settings for these carts. Making it automatic is a default-behaviour change and is recorded in the plan. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../rustynes-test-harness/tests/common/mod.rs | 28 +++++- .../tests/external_coverage.rs | 87 +++++++++++++++---- ...mapper_015_Multicart15_Bio_Hazard_Unl.snap | 11 +-- ...__mapper_015_Multicart15_Subor_V1_0_R.snap | 12 +-- ...r_015_Multicart15_Xiao_Au_Jiang_Wu_Ch.snap | 11 +-- ...chen8259C_Final_Combat_Sachen_JAP_zip.snap | 11 +-- ...2_WaixingFS304_Xi_You_Ji_Hou_Zhuan_Ch.snap | 11 +-- ...ingFinalFantasy_Digital_Dragon_Ch_zip.snap | 11 +-- ...ck_Sansuu_1_Nen_Keisan_Game_Japan_zip.snap | 11 +-- ...ck_Sansuu_2_Nen_Keisan_Game_Japan_zip.snap | 11 +-- ...ck_Sansuu_3_Nen_Keisan_Game_Japan_zip.snap | 11 +-- ...MC_1200in1_Biohazard_China_Unl_En_1_0.snap | 11 +-- ...7_BMC_1200in1_Xiao_Ao_Jiang_Wu_Ch_Wxn.snap | 11 +-- to-dos/plans/v3.2.0-plan.md | 13 +++ 14 files changed, 174 insertions(+), 76 deletions(-) diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index 38a7a0f6..47d0f4f0 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -282,7 +282,7 @@ pub mod external { use std::fs; use std::path::PathBuf; - use rustynes_core::{Buttons, Nes}; + use rustynes_core::{Buttons, ConsoleModel, Nes}; use rustynes_test_harness::coverage::{FrameHealth, frame_health}; use sha2::{Digest, Sha256}; @@ -359,7 +359,7 @@ pub mod external { #[must_use] pub const fn total_frames(&self) -> u32 { match *self { - Self::IdleOnly { frames } => frames, + Self::IdleOnly { frames } | Self::Presses { frames, .. } => frames, Self::StartTap { idle_pre, idle_post, @@ -380,7 +380,6 @@ pub mod external { free_run, .. } => warmup + taps.saturating_sub(1) * period + 1 + free_run, - Self::Presses { frames, .. } => frames, } } } @@ -756,8 +755,25 @@ pub mod external { /// /// Panics on a file-read / archive-extract / parse failure (hard-fail in a /// regression harness); a missing FDS BIOS is the one soft case. - #[allow(clippy::too_many_lines)] pub fn run_capture_opt(rom_rel: &str, script: InputScript) -> Option { + run_capture_model(rom_rel, script, ConsoleModel::Nes) + } + + /// Like [`run_capture_opt`], on the given [`ConsoleModel`]. + /// + /// v3.2.0: some dumps write `$2000`/`$2006` before the front-loading NES + /// PPU leaves its power-on warm-up, so on the default [`ConsoleModel::Nes`] + /// those writes are ignored and the game never draws. On a Famicom the PPU + /// is never held in reset and they land. The coverage sweep boots such + /// dumps the way a player must run them, as a Famicom, instead of listing + /// them as blank. The model is applied with a power-cycle, so the warm-up + /// counter starts from the model's own state rather than the NES one. + #[allow(clippy::too_many_lines)] + pub fn run_capture_model( + rom_rel: &str, + script: InputScript, + model: ConsoleModel, + ) -> Option { let path = external_rom_path(rom_rel); // The SHA-256 pins the *staged file* (archive bytes for a `.zip`/`.7z`, // disk bytes for a `.fds`, ROM bytes for a `.nes`) — a stable identity @@ -780,6 +796,10 @@ pub mod external { Load::Ok(nes) => *nes, Load::SkipNoBios => return None, }; + if model != ConsoleModel::Nes { + nes.set_console_model(model); + nes.power_cycle(); + } // Vs. System setup. Vs. arcade carts (iNES mapper 99, mapper 151, or // the NES-2.0/header Vs. console flag) boot to an attract loop and diff --git a/crates/rustynes-test-harness/tests/external_coverage.rs b/crates/rustynes-test-harness/tests/external_coverage.rs index d266e25c..7a310d68 100644 --- a/crates/rustynes-test-harness/tests/external_coverage.rs +++ b/crates/rustynes-test-harness/tests/external_coverage.rs @@ -118,7 +118,8 @@ mod common; use std::fs; use std::path::{Path, PathBuf}; -use common::external::{InputScript, run_capture_opt, snapshot_text}; +use common::external::{InputScript, run_capture_model, snapshot_text}; +use rustynes_core::ConsoleModel; /// Default boot capture for breadth coverage. A discovered ROM's intro /// structure is unknown, so a passive idle frequently lands on a black @@ -194,16 +195,15 @@ const UNSUPPORTED: &[(&str, &str)] = &[ /// unread. v2.9.8 cut the list to 52: the dirty-tail header rule and the game /// database's NES 2.0 guard made seven of them render, two Doraemon hacks moved /// to `UNSUPPORTED` as the mapper 8 images they are, and one documented -/// blank, Famicom Jump II, was added. +/// blank, Famicom Jump II, was added. v3.2.0 cut it to 41: ten of the 52 were booted on +/// the wrong console ([`FAMICOM_WIRED`]), and the Subor cart blanks its own +/// menu at about f375, which the START taps ran past (see `capture_override`). const KNOWN_BLANK: &[&str] = &[ "fds/Akumajou Dracula (Japan) (Rev 2) (Disk Writer).fds", "mapper-009-MMC2/Gradius II (J) (VC).zip", - "mapper-015-Multicart15/Bio Hazard (Unl) [!].nes", "mapper-015-Multicart15/Doraemon (J) (PRG0) [hM15].zip", "mapper-015-Multicart15/Dragon Ball - Shen Long no Nazo (J) [hM15].zip", "mapper-015-Multicart15/Mobile Suit Z Gundam - Hot Scramble (J) [hM15].zip", - "mapper-015-Multicart15/Subor V1.0 (R).nes", - "mapper-015-Multicart15/Xiao Au Jiang Wu (Ch) [!].nes", "mapper-021-VRC2-VRC4/TwinBee Yahoo!! - Over the Sea Music Demo (PD).zip", "mapper-030-UNROM512/Chu Liu Xiang (Ch) (Wxn).nes", "mapper-034-BNROM-NINA001/Bio Senshi Dan - Increaser Tono Tatakai (J) [hM34].zip", @@ -227,7 +227,6 @@ const KNOWN_BLANK: &[&str] = &[ "mapper-099-VsSystem/Tennis (VS) [!].nes", "mapper-099-VsSystem/Wrecking Crew (VS) [!].nes", "mapper-112-NTDEC-Asder/Fighting Hero III (Unl) [!].nes", - "mapper-139-Sachen8259C/Final Combat (Sachen-JAP) [!].zip", "mapper-142-KaiserKS7032/Pipe 5 (Sachen) [!].zip", // Added at v2.9.8 (maintainer decision, 2026-10-01). Unlike the rest, this // is the game behaving as documented, not a defect: with its battery WRAM @@ -237,16 +236,9 @@ const KNOWN_BLANK: &[&str] = &[ "mapper-153-BandaiLZ93D50/Famicom Jump II - Saikyou no 7 Nin (Japan).nes", "mapper-162-WaixingFS304/Chong Wu Jin Hua Shi (Pet Evolve) (ES-1085) (Ch).nes", "mapper-162-WaixingFS304/Shu Ma Bao Bei (Digimon Crystal) (Ch) (Wxn).nes", - "mapper-162-WaixingFS304/Xi You Ji Hou Zhuan (Ch).nes", - "mapper-164-WaixingFinalFantasy/Digital Dragon (Ch) [!].zip", "mapper-177-Hengedianzi/Xing He Zhan Shi (Ch).nes", "mapper-178-WaixingEdu/Xing Ji Zheng Ba (Ch).nes", - "mapper-185-CNROM-Lock/Sansuu 1 Nen - Keisan Game (Japan).zip", - "mapper-185-CNROM-Lock/Sansuu 2 Nen - Keisan Game (Japan).zip", - "mapper-185-CNROM-Lock/Sansuu 3 Nen - Keisan Game (Japan).zip", "mapper-205-BMC-JC016/4-in-1 (K-3131GS, GN-45) [p1][!].zip", - "mapper-227-BMC-1200in1/Biohazard (China) (Unl) (En) (1.0).nes", - "mapper-227-BMC-1200in1/Xiao Ao Jiang Wu (Ch) (Wxn).nes", "vs-system/GVS Balloon Fight (Dual).nes", "vs-system/GVS Balloon Fight.nes", "vs-system/GVS Mahjong.nes", @@ -255,6 +247,36 @@ const KNOWN_BLANK: &[&str] = &[ "vs-system/GVS Wrecking Crew.nes", ]; +/// Staged dumps that boot only with Famicom reset wiring (v3.2.0). +/// +/// Each writes `$2000`, `$2001`, `$2005` or `$2006` within the first ~29,658 +/// CPU cycles after power-on, the window in which a front-loading NES PPU +/// ignores those registers (NESdev "PPU power up state"). The Famicom does +/// not hold its PPU in reset, so on one the writes land and the game draws. +/// These are Famicom-market and Chinese-market carts that were never meant +/// for the NES, so the sweep boots them as a Famicom +/// ([`rustynes_core::ConsoleModel::Famicom`]) instead of listing them blank. +/// Until v3.2.0 all ten sat in [`KNOWN_BLANK`]. +/// +/// The list is a ratchet as well: each entry is ALSO booted on the default +/// NES model, and that boot must still be blank. A dump that starts drawing on +/// the NES model no longer needs Famicom wiring and fails the sweep until it +/// leaves this list. The frontend does not pick the model from the ROM (the +/// design in `ConsoleModel`'s docs: NES 2.0 cannot say which console a cart is +/// for); the player chooses Famicom in the settings. +const FAMICOM_WIRED: &[&str] = &[ + "mapper-015-Multicart15/Bio Hazard (Unl) [!].nes", + "mapper-015-Multicart15/Xiao Au Jiang Wu (Ch) [!].nes", + "mapper-139-Sachen8259C/Final Combat (Sachen-JAP) [!].zip", + "mapper-162-WaixingFS304/Xi You Ji Hou Zhuan (Ch).nes", + "mapper-164-WaixingFinalFantasy/Digital Dragon (Ch) [!].zip", + "mapper-185-CNROM-Lock/Sansuu 1 Nen - Keisan Game (Japan).zip", + "mapper-185-CNROM-Lock/Sansuu 2 Nen - Keisan Game (Japan).zip", + "mapper-185-CNROM-Lock/Sansuu 3 Nen - Keisan Game (Japan).zip", + "mapper-227-BMC-1200in1/Biohazard (China) (Unl) (En) (1.0).nes", + "mapper-227-BMC-1200in1/Xiao Ao Jiang Wu (Ch) (Wxn).nes", +]; + const DEFAULT_CAPTURE: InputScript = InputScript::RepeatStartTap { warmup: 240, period: 150, @@ -283,6 +305,15 @@ fn capture_override(rom_rel: &str) -> Option { const IDLE: InputScript = InputScript::IdleOnly { frames: 700 }; // A few titles need a longer fade to settle on the title screen. const IDLE_LONG: InputScript = InputScript::IdleOnly { frames: 1200 }; + // v3.2.0: the Subor keyboard cart draws its menu by f100 and blanks it at + // about f375 (with no keyboard attached), so neither the START taps nor a + // 700-frame idle ends on it. It was first put in FAMICOM_WIRED, whose + // ratchet refused it: on the NES model it draws the same menu. + const SUBOR_MENU: InputScript = InputScript::Presses { + presses: &[], + frames: 350, + checkpoints: &[100, 350], + }; let idle = [ "mapper-000-NROM/Gyromite.nes", "mapper-001-MMC1/Dr. Mario.nes", @@ -299,7 +330,9 @@ fn capture_override(rom_rel: &str) -> Option { "mapper-119-TQROM/Pin Bot (E) [!].nes", ]; let idle_long = ["mapper-001-MMC1/Tecmo Bowl.nes"]; - if idle.contains(&rom_rel) { + if rom_rel == "mapper-015-Multicart15/Subor V1.0 (R).nes" { + Some(SUBOR_MENU) + } else if idle.contains(&rom_rel) { Some(IDLE) } else if idle_long.contains(&rom_rel) { Some(IDLE_LONG) @@ -646,8 +679,27 @@ fn external_coverage_boot_smoke() { let capture_script = capture_override(rom_rel).unwrap_or(DEFAULT_CAPTURE); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe( move || -> Result, String> { + let famicom = FAMICOM_WIRED.contains(&rom.as_str()); + let model = if famicom { + ConsoleModel::Famicom + } else { + ConsoleModel::Nes + }; + // The FAMICOM_WIRED ratchet: on the NES model the same boot + // must still be blank, or the entry is no longer needed. It runs + // FIRST so the frames `RUSTYNES_DUMP_FRAMES` writes for review + // are the Famicom boot's, which overwrites them. + if famicom { + let nes_boot = run_capture_model(&rom, capture_script, ConsoleModel::Nes) + .expect("a FAMICOM_WIRED entry is a cartridge, not an FDS disk"); + if !nes_boot.best_frame_health.looks_blank() { + return Err("in FAMICOM_WIRED but also draws on the NES model; \ + remove it from the list" + .to_string()); + } + } // FDS disk on a BIOS-less checkout -> clean skip (Ok(None)). - let Some(capture) = run_capture_opt(&rom, capture_script) else { + let Some(capture) = run_capture_model(&rom, capture_script, model) else { return Ok(None); }; @@ -677,7 +729,10 @@ fn external_coverage_boot_smoke() { }; // (2) Baseline snapshot comparison. - let text = snapshot_text(&rom, capture_script, &capture); + let mut text = snapshot_text(&rom, capture_script, &capture); + if famicom { + text.insert_str(0, "console: Famicom (FAMICOM_WIRED)\n"); + } insta::assert_snapshot!(snap.as_str(), text); // Snapshot passed; report the health verdict (if blank). diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Bio_Hazard_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Bio_Hazard_Unl.snap index 7d65dc77..ea3248a0 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Bio_Hazard_Unl.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Bio_Hazard_Unl.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-015-Multicart15/Bio Hazard (Unl) [!].nes rom_sha256=e35cd7587cc1e03226b861a687a6f35994cb3ff839caa67ab1a83331966397d7 frames=1141 fb_bytes=245760 -cycles=33949963 -audio_samples=836509 -audio_fnv1a64=4767b573c7c7a344 -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949772 +audio_samples=836505 +audio_fnv1a64=d18078d274dd47e8 +checkpoint f900 fb_fnv1a64=d0087fddbf46d25d +checkpoint f1100 fb_fnv1a64=d3f67e489ab8a1f4 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Subor_V1_0_R.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Subor_V1_0_R.snap index 2f191e0e..962f308d 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Subor_V1_0_R.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Subor_V1_0_R.snap @@ -4,10 +4,10 @@ expression: text --- rom=mapper-015-Multicart15/Subor V1.0 (R).nes rom_sha256=c6c6ba22d055910adb061b4256d558a97a773c117e9fdd527c2198b61a43764f -frames=1141 +frames=350 fb_bytes=245760 -cycles=11373864 -audio_samples=280234 -audio_fnv1a64=bd04a247f901560d -checkpoint f900 fb_fnv1a64=1719dca5cef7a325 -checkpoint f1100 fb_fnv1a64=1719dca5cef7a325 +cycles=10393398 +audio_samples=256076 +audio_fnv1a64=373ad062b19b55b6 +checkpoint f100 fb_fnv1a64=89ee4c476c97a325 +checkpoint f350 fb_fnv1a64=83e340f562b3ba88 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Xiao_Au_Jiang_Wu_Ch.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Xiao_Au_Jiang_Wu_Ch.snap index 4b2e5b58..5cbf64d4 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Xiao_Au_Jiang_Wu_Ch.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_015_Multicart15_Xiao_Au_Jiang_Wu_Ch.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-015-Multicart15/Xiao Au Jiang Wu (Ch) [!].nes rom_sha256=a263fd76119a6b20acabb7e724c42c36af54141bee75b5eb51f6c8c280f85dac frames=1141 fb_bytes=245760 -cycles=33949960 -audio_samples=836509 -audio_fnv1a64=abdcfaf134b65f2f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949771 +audio_samples=836505 +audio_fnv1a64=5beb95543e60cab9 +checkpoint f900 fb_fnv1a64=46364cea8fc47679 +checkpoint f1100 fb_fnv1a64=cbea414b587c74a9 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_139_Sachen8259C_Final_Combat_Sachen_JAP_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_139_Sachen8259C_Final_Combat_Sachen_JAP_zip.snap index d40f40d6..87b5fc62 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_139_Sachen8259C_Final_Combat_Sachen_JAP_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_139_Sachen8259C_Final_Combat_Sachen_JAP_zip.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-139-Sachen8259C/Final Combat (Sachen-JAP) [!].zip rom_sha256=7f476378e8e85ba34efbaf1afacd7a009fbf978831f6bd30d8ffaf3ea52ef6d6 frames=1141 fb_bytes=245760 -cycles=33949961 -audio_samples=836509 -audio_fnv1a64=15766b4c4a876f4f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949772 +audio_samples=836505 +audio_fnv1a64=2b61e89f716100d8 +checkpoint f900 fb_fnv1a64=2b420ea2f2d8e479 +checkpoint f1100 fb_fnv1a64=e6572a6997392c44 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_162_WaixingFS304_Xi_You_Ji_Hou_Zhuan_Ch.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_162_WaixingFS304_Xi_You_Ji_Hou_Zhuan_Ch.snap index 6fe405a4..0f5b2224 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_162_WaixingFS304_Xi_You_Ji_Hou_Zhuan_Ch.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_162_WaixingFS304_Xi_You_Ji_Hou_Zhuan_Ch.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-162-WaixingFS304/Xi You Ji Hou Zhuan (Ch).nes rom_sha256=a6ebb82b6d0c1b28f80f97e43232b3a9851ce5a1fa5c4bf617c4482d34c33029 frames=1141 fb_bytes=245760 -cycles=33949960 -audio_samples=836509 -audio_fnv1a64=15766b4c4a876f4f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949777 +audio_samples=836505 +audio_fnv1a64=dfaf9a28289fb4f6 +checkpoint f900 fb_fnv1a64=49fe7d08a24bcbd5 +checkpoint f1100 fb_fnv1a64=4243b6b619e03142 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_164_WaixingFinalFantasy_Digital_Dragon_Ch_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_164_WaixingFinalFantasy_Digital_Dragon_Ch_zip.snap index 0a28d867..89b2894b 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_164_WaixingFinalFantasy_Digital_Dragon_Ch_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_164_WaixingFinalFantasy_Digital_Dragon_Ch_zip.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-164-WaixingFinalFantasy/Digital Dragon (Ch) [!].zip rom_sha256=feb75b86cda5a76195107f9e6224df27cb485179ae0c0fc28480659dfc3bfb5f frames=1141 fb_bytes=245760 -cycles=33949961 -audio_samples=836509 -audio_fnv1a64=1e782101c2d1ead8 -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949772 +audio_samples=836505 +audio_fnv1a64=54ca0cce48474f77 +checkpoint f900 fb_fnv1a64=032dc90db559e633 +checkpoint f1100 fb_fnv1a64=032dc90db559e633 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_1_Nen_Keisan_Game_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_1_Nen_Keisan_Game_Japan_zip.snap index d05e6afe..4f8bfa62 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_1_Nen_Keisan_Game_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_1_Nen_Keisan_Game_Japan_zip.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-185-CNROM-Lock/Sansuu 1 Nen - Keisan Game (Japan).zip rom_sha256=34db2c0d400d1fc187cc543c90703442d17cfa9d6d26b9c8162d3efe8d782b9a frames=1141 fb_bytes=245760 -cycles=33949960 -audio_samples=836509 -audio_fnv1a64=15766b4c4a876f4f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949774 +audio_samples=836505 +audio_fnv1a64=967038e67827fae5 +checkpoint f900 fb_fnv1a64=7968206e1c6f6ffc +checkpoint f1100 fb_fnv1a64=244d500da71f4978 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_2_Nen_Keisan_Game_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_2_Nen_Keisan_Game_Japan_zip.snap index eb58c1cb..fea58686 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_2_Nen_Keisan_Game_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_2_Nen_Keisan_Game_Japan_zip.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-185-CNROM-Lock/Sansuu 2 Nen - Keisan Game (Japan).zip rom_sha256=c9ab417d19f70aa6c4a90271f7fbd8cc0a66644faa644a35b3bc9ad9d0941e1d frames=1141 fb_bytes=245760 -cycles=33949961 -audio_samples=836509 -audio_fnv1a64=15766b4c4a876f4f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949772 +audio_samples=836505 +audio_fnv1a64=f623cf8f0f88f38e +checkpoint f900 fb_fnv1a64=b9cf96feb8be9bd2 +checkpoint f1100 fb_fnv1a64=350f2544ae026ad4 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_3_Nen_Keisan_Game_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_3_Nen_Keisan_Game_Japan_zip.snap index 6db25ac5..dfc0a61f 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_3_Nen_Keisan_Game_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_185_CNROM_Lock_Sansuu_3_Nen_Keisan_Game_Japan_zip.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-185-CNROM-Lock/Sansuu 3 Nen - Keisan Game (Japan).zip rom_sha256=9a69d317193290ae43d7680bf9f3ab3e07954ea25663776d51d938f524105c5a frames=1141 fb_bytes=245760 -cycles=33949962 -audio_samples=836509 -audio_fnv1a64=15766b4c4a876f4f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949773 +audio_samples=836505 +audio_fnv1a64=253b7d52410910de +checkpoint f900 fb_fnv1a64=b6c7b50ac0690163 +checkpoint f1100 fb_fnv1a64=b6c7b50ac0690163 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Biohazard_China_Unl_En_1_0.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Biohazard_China_Unl_En_1_0.snap index 7c5c7ca9..ad731971 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Biohazard_China_Unl_En_1_0.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Biohazard_China_Unl_En_1_0.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-227-BMC-1200in1/Biohazard (China) (Unl) (En) (1.0).nes rom_sha256=8d6c562894f9a3e7cc65dc49e7adfff257b4d227a9aa6455df8b5ec80ffc8c6f frames=1141 fb_bytes=245760 -cycles=33949961 -audio_samples=836509 -audio_fnv1a64=91d3042e54f68a51 -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949774 +audio_samples=836505 +audio_fnv1a64=2c0f22dda45c7a85 +checkpoint f900 fb_fnv1a64=e685c0b065d710d5 +checkpoint f1100 fb_fnv1a64=938d45252f004a5c diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Xiao_Ao_Jiang_Wu_Ch_Wxn.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Xiao_Ao_Jiang_Wu_Ch_Wxn.snap index c51a0988..4e3808b8 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Xiao_Ao_Jiang_Wu_Ch_Wxn.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_227_BMC_1200in1_Xiao_Ao_Jiang_Wu_Ch_Wxn.snap @@ -2,12 +2,13 @@ source: crates/rustynes-test-harness/tests/external_coverage.rs expression: text --- +console: Famicom (FAMICOM_WIRED) rom=mapper-227-BMC-1200in1/Xiao Ao Jiang Wu (Ch) (Wxn).nes rom_sha256=fddaf26acd3d1d507e75b6649fbeb9988954884947dd450021611e4b24776d77 frames=1141 fb_bytes=245760 -cycles=33949962 -audio_samples=836509 -audio_fnv1a64=abdcfaf134b65f2f -checkpoint f900 fb_fnv1a64=89ee4c476c97a325 -checkpoint f1100 fb_fnv1a64=89ee4c476c97a325 +cycles=33949772 +audio_samples=836505 +audio_fnv1a64=bb8cd7ce44474332 +checkpoint f900 fb_fnv1a64=46364cea8fc47679 +checkpoint f1100 fb_fnv1a64=cbea414b587c74a9 diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index 29f7708c..d9422521 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -67,6 +67,19 @@ until the hardware release shipped.) The codename is asked at the cut. (arbitrary single-frame presses) drives DOWN then START on the cart's game select, which reaches *Nintendo World Cup* through the block register; a mutant that ignores the block bit fails `extended_m47_nintendo_world_cup_block_1`. +- **Ten "blank" dumps need Famicom wiring, not a fix.** *Sansuu 1/2/3* (185), + *Bio Hazard*, *Xiao Au Jiang Wu* (015), *Final Combat* (139), *Xi You Ji Hou + Zhuan* (162), *Digital Dragon* (164), and both 227 dumps write the PPU + registers inside the front-loading NES's power-on warm-up, where they are + ignored; with `ConsoleModel::Famicom` all ten draw their titles. The coverage + sweep now boots them as a Famicom (`FAMICOM_WIRED`), with a ratchet that + each must still be blank on the NES model; a mutant that drops the model + fails all ten. *Subor V1.0* was not one of them -- the ratchet refused it, + since it draws on the NES too -- it blanks its own menu at about f375, so it + gets a 350-frame capture. KNOWN_BLANK: 52 -> 41. **Maintainer decision, + recorded not taken:** the frontend does not pick the Famicom model from the + ROM (`ConsoleModel`'s stated design); a player has to choose it in Settings. + Auto-selecting it per game would be a default-behaviour change. ## Sprints From 9795e94739e6ca304870e819860376ac3b70ffab Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 22:28:14 -0400 Subject: [PATCH 018/104] test(harness): record why Gradius II (VC) and Chu Liu Xiang stay blank The last two KNOWN_BLANK entries the v3.2.0 triage set out to explain now carry their measured causes at the entry. Neither is an emulator defect that a change here would fix. Gradius II (J) (VC) is mis-headered. Its header says mapper 9 with the Vs. System bit set ($4E45531A 08 10 91 01). The 256 KiB payload is the Konami VRC4 cartridge's: compared byte for byte with the staged mapper-25 dump, 4 of 262,144 bytes differ, all in one data table at PRG $0E1D-$0E20 (the Virtual Console port's edit). Booted as staged it shows one colour; given the cartridge's own NES 2.0 header (mapper 25, submapper 1) it reaches 10 colours over the same 1,141-frame capture. The real cart is already covered under mapper 25. Chu Liu Xiang (Ch) (Wxn) stays open. It is a converted Waixing .WXN image whose header reads as mapper 30 with CHR-ROM, so the loader routes it to 176 submapper 2 (NESdev, UNROM 512 "Secondary use for converted .WXN files"), exactly like Mo Shen Fa Shi and Shui Hu Zhuan in the same directory, which share the identical 16-byte header and boot. A CPU trace shows it running the FS005 copy-protection sequence the 176 page documents ($A001 = $E0, the $5010/$5012 outer registers, the XOR-decoded stub at $0100, which is a correct JMP $FF1D), then entering data at $FF40 in frame 4 and halting on a JAM opcode by frame 7. Re-headered as NES 2.0 176 submappers 0-5 and as mappers 162, 163, 164, 178, 74, 192, 195, 245 and 253, it never draws more than two colours; the control, Shui Hu Zhuan, draws only under 176/2, which validates the experiment. With no second dump of the title in either local set, a bad or differently- converted dump is the leading explanation; it is not established. Comments only; no behaviour change. The plan's Findings section records both and marks item 2 done (KNOWN_BLANK 52 -> 41). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../rustynes-test-harness/tests/external_coverage.rs | 10 ++++++++++ to-dos/plans/v3.2.0-plan.md | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/rustynes-test-harness/tests/external_coverage.rs b/crates/rustynes-test-harness/tests/external_coverage.rs index 7a310d68..266e425a 100644 --- a/crates/rustynes-test-harness/tests/external_coverage.rs +++ b/crates/rustynes-test-harness/tests/external_coverage.rs @@ -200,11 +200,21 @@ const UNSUPPORTED: &[(&str, &str)] = &[ /// menu at about f375, which the START taps ran past (see `capture_override`). const KNOWN_BLANK: &[&str] = &[ "fds/Akumajou Dracula (Japan) (Rev 2) (Disk Writer).fds", + // Cause measured at v3.2.0: a mis-headered dump. The payload is the VRC4 + // cartridge's (mapper 25; four bytes of a data table differ, the Virtual + // Console port's edit), under a mapper-9 header with the Vs. bit set. It + // draws with the cartridge's header; the real cart is staged under 25. "mapper-009-MMC2/Gradius II (J) (VC).zip", "mapper-015-Multicart15/Doraemon (J) (PRG0) [hM15].zip", "mapper-015-Multicart15/Dragon Ball - Shen Long no Nazo (J) [hM15].zip", "mapper-015-Multicart15/Mobile Suit Z Gundam - Hot Scramble (J) [hM15].zip", "mapper-021-VRC2-VRC4/TwinBee Yahoo!! - Over the Sea Music Demo (PD).zip", + // Open at v3.2.0. A converted Waixing .WXN image, routed to 176/2 like its + // two siblings in that directory, which boot. It runs the FS005 copy- + // protection sequence and the decoded stub at $0100 correctly, then crashes + // into data at $FF40 in frame 4 and halts on a JAM. Re-headered as each 176 + // submapper and ten other Waixing mappers it never draws. A bad or + // differently-converted dump is the leading explanation; no second dump. "mapper-030-UNROM512/Chu Liu Xiang (Ch) (Wxn).nes", "mapper-034-BNROM-NINA001/Bio Senshi Dan - Increaser Tono Tatakai (J) [hM34].zip", "mapper-034-BNROM-NINA001/Dragon Ball - Shen Long no Nazo (J) [hM34].zip", diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index d9422521..f33407aa 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -80,13 +80,21 @@ until the hardware release shipped.) The codename is asked at the cut. recorded not taken:** the frontend does not pick the Famicom model from the ROM (`ConsoleModel`'s stated design); a player has to choose it in Settings. Auto-selecting it per game would be a default-behaviour change. +- **The last two triaged blanks have causes, not fixes.** *Gradius II (VC)* is + mis-headered: the payload is the VRC4 cartridge's (four bytes differ, the + Virtual Console edit) under a mapper-9 Vs. header, and it draws when given + the cartridge's mapper-25 header. *Chu Liu Xiang* is open: routed to 176/2 + like its two working siblings, it runs the FS005 copy-protection stub + correctly, then crashes into data at `$FF40` in frame 4; no 176 submapper + or other Waixing mapper boots it, so a bad dump is the leading explanation. + Both stay in KNOWN_BLANK with the cause at the entry. Item 2 is done. ## Sprints | Sprint | Deliverable | Gate | Status | | --- | --- | --- | --- | | 3.2-S1 | Items 1 and 4 (oracle families and the corpus) | rows 1, 4 | — | -| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 in progress | +| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 done (KNOWN_BLANK 52 -> 41, every remaining entry triaged or previously documented) | | 3.2-S3 | Item 5, the sibling options, first | row 5 | — | | 3.2-S4 | Items 6 and 7, the sibling mappers | rows 6, 7 | — | | 3.2-S5 | Item 8, then the seed sweep and the release cut | row 8; the sweep; both ladders | — | From 530708f92a603ca7e0337e51567ec916e100c768 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 22:34:37 -0400 Subject: [PATCH 019/104] feat(mappers): add mapper 116 (SOMARI-P, VRC2 / MMC3 / MMC1 multi-mode) Mapper 116 is Gouder's SOMARI-P board: the Huang-1 / Huang-2 ASIC plus a PAL that simulates three different mapper chips and switches between them at run time. Written from the vendored INES_Mapper_116.md and the pages of the three chips (VRC2_and_VRC4.md for the VRC2b wiring, MMC3.md, MMC1.md), in a new m116_somari.rs; no reference-emulator source was read. The board, per the page - Supervisor register at $4100 (mask $E100): MM selects VRC2 (0), MMC3 (1) or MMC1 (2, 3); bit 2 is CHR A18, an outer 256 KiB CHR bank in every mode. - "All three simulated chips have their separate registers": each chip keeps its own state; only the selected one takes $8000-$FFFF writes and drives PRG, CHR, mirroring and the IRQ line. The MMC3 and MMC1 are the project's own Mmc3 / Mmc1 used as register files (both without provenance headers); the VRC2 is written here. - VRC2b: PRG 0 at $8xxx, mirroring at $9xxx (bit 1 ignored, as on the VRC2), PRG 1 at $Axxx, each answering in its whole 4 KiB page because Garou Densetsu Special writes the mirrors; 1 KiB CHR selects at $B000-$E003 by A12 and A1, nibble by A0, with the VRC2's four high bits; last 16 KiB fixed; CHR selects power up at $FF (the page: the original Somari relies on it). - Switching INTO MMC1 mode empties the MMC1 serial shift register (Garou Densetsu Special relies on it). Mmc1 gains a pub(crate) reset_shift_register() that clears only the shift, not control (a bit-7 write would also force PRG mode 3, which the page does not say); nothing else in m001_mmc1.rs changes. - Huang-2: the MMC1 PRG register is written one bit to the left. No submapper exists; the page's heuristic (PRG and CHR both 128 KiB) selects it. Chosen where the page is silent: power-on in VRC2 mode (the note about Somari depending on the VRC2 CHR power-up value only makes sense if the board starts there); deselected chips keep receiving the A12 and CPU- cycle clocks; the MMC3 IRQ drives the line only in MMC3 mode and its state survives a switch away and back. Not modelled: the VRC2's $6000 microwire latch, and submapper 3 (one sentence, no registers). Verification - Red first: a parse test of an iNES mapper-116 image failed with the id unsupported, then passed. - 12 unit tests: the supervisor decode ($4100, $41FF, $5F00 hit; $4200, $5000, $6100 miss); VRC2 power-on and CHR $FF; VRC2 PRG / mirroring across their whole pages and the fixed last 16 KiB; all eight VRC2 CHR selects with bit 4 ignored; MMC3 mode with its IRQ deselected in VRC2 mode and restored on return; each chip keeping its registers across switches (mode 3 = MMC1 too); MMC1 PRG mode 3 and 0, 4 KiB and 8 KiB CHR, mirroring; the shift reset on switching in (and not on a 2 -> 3 re-selection); CHR A18 in all three modes; Huang-2 versus Huang-1; state round trip and an overflowing embedded length. - Boot snapshots: Somari (SOMARI-W) (Unl) [!], START at 400, checkpoint f1000 = Green Hill Zone act 1 in play (Somari, score / timer HUD, lives counter, scrolling zone), the committed screenshot; Kart Fighter (SOMARI-W) (Unl) [!], 600 frames idle, its title. Probes (not snapshotted) also show Kart Fighter's character select and a fight, Garou Densetsu Special's title, select and a fight, and A.V. Girl Fighting's title and a fight (512 KiB CHR, supervisor $05). Instrumenting the supervisor showed every one of the four cycling through all three chips while initialising, then running in MMC3. - Tier: Curated on the two D23 records. - Mutations (each restored from a copy): 1. VRC2 CHR power-up $00 instead of $FF: caught by the unit test ("slot 0", left 0 right 255); NOT caught by either boot snapshot. 2. VRC2 mirroring from bit 1: caught by the unit test (left Vertical right Horizontal); NOT caught by the boot snapshots. 3. Modes 2 / 3 decoded as VRC2 instead of MMC1: caught by 5 unit tests and by both boot snapshots (Kart Fighter f600 925de2c9... -> 89ee4c47..., Somari f1000 bd784cd0... -> c263bed2...). So the VRC2 (and MMC1) bank details rest on the unit tests: the games only pass through those modes during initialisation. Not verified: Huang-2 (no 128/128 KiB dump staged, the Gouder A.V. Jiu Ji Ma Jiang II is not in the set); any game that stays in VRC2 or MMC1 mode. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 7 + crates/rustynes-mappers/src/m001_mmc1.rs | 9 + crates/rustynes-mappers/src/m116_somari.rs | 461 ++++++++++++++++++ .../rustynes-mappers/src/m116_somari_tests.rs | 305 ++++++++++++ crates/rustynes-mappers/src/tier.rs | 7 +- .../tests/external_extended.rs | 28 ++ ..._extended__extended_m116_kart_fighter.snap | 12 + ...ternal_extended__extended_m116_somari.snap | 12 + docs/compatibility.md | 2 + docs/mappers.md | 3 +- .../Kart Fighter (SOMARI-W) (Unl) [!].png | Bin 0 -> 5070 bytes .../Somari (SOMARI-W) (Unl) [!].png | Bin 0 -> 7264 bytes 12 files changed, 842 insertions(+), 4 deletions(-) create mode 100644 crates/rustynes-mappers/src/m116_somari.rs create mode 100644 crates/rustynes-mappers/src/m116_somari_tests.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_kart_fighter.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_somari.snap create mode 100644 screenshots/external/mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].png create mode 100644 screenshots/external/mapper-116-SomariW/Somari (SOMARI-W) (Unl) [!].png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 4cf91d9a..2cd6bc72 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -110,6 +110,7 @@ mod m099_vs_system; mod m105_nes_event; mod m107_magic_dragon107; mod m113_ave_nina006; +mod m116_somari; mod m118_txsrom; mod m119_tqrom; mod m132_txc_22211; @@ -221,6 +222,7 @@ pub use m099_vs_system::VsSystem; pub use m105_nes_event::{NWC_TOURNAMENT_DIP, NesEvent105}; pub use m107_magic_dragon107::MagicDragon107; pub use m113_ave_nina006::Nina006M113; +pub use m116_somari::Somari116; pub use m118_txsrom::TxSrom; pub use m119_tqrom::Tqrom; pub use m132_txc_22211::Txc132; @@ -1418,6 +1420,11 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { chr_rom, &h, )?, + // SOMARI-P (Huang-1 / Huang-2): VRC2 / MMC3 / MMC1 by a supervisor. + 116 => Box::new( + Somari116::new(prg_rom, chr_rom, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), // MMC3 + MMC2-style CHR latches and a 4 KiB CHR-RAM page. 165 => mmc3_ext(Mmc3ExtBoardKind::M165, prg_rom, chr_rom, &h)?, // Sugar Softec UNL-8237 (215.0) / UNL-8237A (215.1). diff --git a/crates/rustynes-mappers/src/m001_mmc1.rs b/crates/rustynes-mappers/src/m001_mmc1.rs index d91bb7f2..0c466889 100644 --- a/crates/rustynes-mappers/src/m001_mmc1.rs +++ b/crates/rustynes-mappers/src/m001_mmc1.rs @@ -179,6 +179,15 @@ impl Mmc1 { self.shift_count } + /// Empty the serial port's shift register without touching `control` + /// (unlike a bit-7 write, which also forces PRG mode 3). Mapper 116's + /// PAL does this when its supervisor register switches the board into + /// MMC1 mode (`INES_Mapper_116.md`); v3.2.0. + pub(crate) const fn reset_shift_register(&mut self) { + self.shift = 0x10; + self.shift_count = 0; + } + /// Map a PPU address in `$2000-$3EFF` to a 2 KiB-VRAM offset using the /// current mirroring mode (control bits 1-0). fn nametable_offset(&self, addr: u16) -> usize { diff --git a/crates/rustynes-mappers/src/m116_somari.rs b/crates/rustynes-mappers/src/m116_somari.rs new file mode 100644 index 00000000..09f3a57a --- /dev/null +++ b/crates/rustynes-mappers/src/m116_somari.rs @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Mapper 116: Gouder's SOMARI-P board (the Huang-1 / Huang-2 ASIC and a +//! PAL), which simulates three different mapper chips and switches between +//! them at run time (v3.2.0). +//! +//! **Provenance.** Written from the vendored `INES_Mapper_116.md`, and from +//! the pages of the three chips it simulates: `VRC2_and_VRC4.md` (the VRC2b +//! wiring, A0/A1), `MMC3.md` and `MMC1.md`. The MMC3 and MMC1 are the +//! project's own [`Mmc3`] and [`Mmc1`], used as register files; neither file +//! carries a provenance header. No reference-emulator source was read. +//! +//! ## The board, per the page +//! +//! - **Supervisor register** at `$4100` (mask `$E100`, write-only): +//! `.... .CMM`. `MM` selects the simulated chip (0: VRC2, 1: MMC3, 2 and 3: +//! MMC1); `C` is CHR A18, an outer 256 KiB CHR bank that applies in every +//! mode. +//! - **Separate state per chip.** "All three simulated chips have their +//! separate registers, so that when switching between modes ... the entire +//! mapper state changes including PRG/CHR ROM switchable and fixed banks as +//! well as IRQ state." So each chip keeps its own registers here, only the +//! selected one receives `$8000-$FFFF` writes, and only the selected one +//! drives the banks, the mirroring and the IRQ line. +//! - **VRC2 (VRC2b wiring):** PRG select 0 at `$8xxx`, mirroring at `$9xxx`, +//! PRG select 1 at `$Axxx` (the page: *Garō Densetsu Special* writes to +//! mirrors of all three, so each answers in its whole 4 KiB); 1 KiB CHR +//! selects at `$B000-$E003` decoded by A12 and A0/A1, low nibble on A0 = 0 +//! and the VRC2's four high bits on A0 = 1; `$C000-$FFFF` fixed to the last +//! 16 KiB. "The VRC2 CHR-ROM registers are initialized on power-up to +//! `$FF`", which *Somari*'s original release relies on. +//! - **MMC3:** a stock MMC3, with its A12 IRQ counter. +//! - **MMC1:** a stock MMC1. "When switching to MMC1 mode, the serial shift +//! register is reset" (*Garō Densetsu Special* relies on it). +//! - **Huang-2:** the MMC1 PRG bank register is "shifted by one bit to the +//! left" relative to a normal MMC1. No submapper exists for it; the page's +//! heuristic (PRG-ROM and CHR-ROM each exactly 128 KiB) selects it. +//! +//! ## Choices the page leaves open +//! +//! - The supervisor's power-on value is not given. It is 0 (VRC2 mode): the +//! page's own note that *Somari* depends on the VRC2 CHR registers' +//! power-up value only makes sense if the board starts in VRC2 mode. +//! - The inactive chips keep receiving the A12 and CPU-cycle clocks (they are +//! one ASIC); only their outputs are deselected. +//! - The VRC2's `$6000-$6FFF` microwire latch is not modelled; nothing on the +//! page suggests these games use it, and the board has no WRAM. +//! - Submapper 3's reset-switched 5-in-1 (named on the page in one sentence, +//! with no register description) is not modelled. + +#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)] + +use crate::cartridge::Mirroring; +use crate::m001_mmc1::Mmc1; +use crate::m004_mmc3::{Mmc3, Mmc3Revision}; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError}; +use crate::mmc3_boards::mirror_bank; +use alloc::string::ToString; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const PRG_BANK_8K: usize = 0x2000; +const PRG_BANK_16K: usize = 0x4000; +const CHR_BANK_1K: usize = 0x0400; +const VRAM_SIZE: usize = 0x0800; + +/// Save-state layout version for [`Somari116`]. +const SAVE_STATE_VERSION: u8 = 1; + +/// The VRC2 CHR registers' power-up value (`INES_Mapper_116.md`). +const VRC2_CHR_POWER_UP: u8 = 0xFF; + +/// Which chip the supervisor register selects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Vrc2, + Mmc3, + Mmc1, +} + +/// Mapper 116 (SOMARI-P, Huang-1 / Huang-2). See the module docs. +pub struct Somari116 { + prg_rom: Box<[u8]>, + /// CHR-ROM, or 8 KiB of CHR-RAM when the image has none. + chr: Box<[u8]>, + chr_is_ram: bool, + /// The console's 2 KiB of nametable RAM, mapped by the active chip's + /// mirroring. + vram: Box<[u8]>, + /// The `$4100` supervisor register. + supervisor: u8, + /// The Huang-2 ASIC: the MMC1 PRG register is shifted left by one. + huang2: bool, + /// VRC2 PRG selects (`$8xxx`, `$Axxx`), 5 bits. + vrc2_prg: [u8; 2], + /// VRC2 mirroring (`$9xxx` bit 0: 0 vertical, 1 horizontal). + vrc2_mirroring: u8, + /// VRC2 1 KiB CHR selects, 8 bits each (4 low + the VRC2's 4 high). + vrc2_chr: [u8; 8], + /// The simulated MMC3: register file and A12 IRQ counter. + mmc3: Mmc3, + /// The simulated MMC1: serial port and register file. + mmc1: Mmc1, +} + +impl Somari116 { + /// Construct the board. The Huang-2 is selected by the page's size + /// heuristic (PRG-ROM and CHR-ROM both 128 KiB). + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of + /// 16 KiB or CHR is not a multiple of 1 KiB. + pub fn new( + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + mirroring: Mirroring, + ) -> Result { + if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) { + return Err(MapperError::Invalid(format!( + "mapper 116 PRG-ROM size {} is not a non-zero multiple of 16 KiB", + prg_rom.len() + ))); + } + if !chr_rom.len().is_multiple_of(CHR_BANK_1K) { + return Err(MapperError::Invalid(format!( + "mapper 116 CHR-ROM size {} is not a multiple of 1 KiB", + chr_rom.len() + ))); + } + let huang2 = prg_rom.len() == 128 * 1024 && chr_rom.len() == 128 * 1024; + let chr_is_ram = chr_rom.is_empty(); + let chr = if chr_is_ram { + vec![0u8; 8 * CHR_BANK_1K].into_boxed_slice() + } else { + chr_rom + }; + // The MMC1 is a register file only: it is handed a minimal ROM it + // never reads, and no PRG-RAM is mapped (the board has none). + let mmc1 = Mmc1::new( + vec![0u8; 2 * PRG_BANK_16K].into_boxed_slice(), + Box::new([]), + mirroring, + 0, + )?; + Ok(Self { + prg_rom, + chr, + chr_is_ram, + vram: vec![0u8; VRAM_SIZE].into_boxed_slice(), + supervisor: 0, + huang2, + vrc2_prg: [0; 2], + vrc2_mirroring: 0, + vrc2_chr: [VRC2_CHR_POWER_UP; 8], + mmc3: Mmc3::register_core(mirroring, Mmc3Revision::Sharp), + mmc1, + }) + } + + const fn mode(&self) -> Mode { + match self.supervisor & 3 { + 0 => Mode::Vrc2, + 1 => Mode::Mmc3, + _ => Mode::Mmc1, + } + } + + /// The 8 KiB PRG bank at `addr` (`$8000-$FFFF`), before the mirror. + fn prg_bank(&self, addr: u16) -> usize { + let a13 = usize::from((addr >> 13) & 1); + match self.mode() { + Mode::Vrc2 => match addr & 0xE000 { + 0x8000 => usize::from(self.vrc2_prg[0] & 0x1F), + 0xA000 => usize::from(self.vrc2_prg[1] & 0x1F), + // The last 16 KiB: the chip's all-ones bank pair. + 0xC000 => 0x1E, + _ => 0x1F, + }, + Mode::Mmc3 => usize::from(self.mmc3.prg_bank_raw(addr)), + Mode::Mmc1 => { + let (control, _, _, prg) = self.mmc1.registers(); + // Huang-2: the game writes the bank one bit to the left. + let bank = usize::from(if self.huang2 { prg >> 1 } else { prg }) & 0x0F; + let a14 = usize::from((addr >> 14) & 1); + let bank16 = match (control >> 2) & 3 { + 0 | 1 => (bank & !1) | a14, + 2 if a14 == 0 => 0, + 3 if a14 == 1 => 0x0F, + _ => bank, + }; + (bank16 << 1) | a13 + } + } + } + + /// The 1 KiB CHR bank at a pattern-table address, before the mirror, + /// with the supervisor's CHR A18 on top. + fn chr_bank(&self, addr: u16) -> usize { + let addr = addr & 0x1FFF; + let inner = match self.mode() { + Mode::Vrc2 => usize::from(self.vrc2_chr[usize::from(addr >> 10)]), + Mode::Mmc3 => self.mmc3.chr_bank_1k(addr) & 0xFF, + Mode::Mmc1 => { + let (control, chr0, chr1, _) = self.mmc1.registers(); + let a12 = addr & 0x1000 != 0; + let bank4k = if control & 0x10 == 0 { + // 8 KiB mode: the low bit of CHR bank 0 is ignored. + (chr0 & 0x1E) | u8::from(a12) + } else if a12 { + chr1 + } else { + chr0 + }; + (usize::from(bank4k & 0x1F) << 2) | usize::from((addr >> 10) & 3) + } + }; + inner | (usize::from((self.supervisor >> 2) & 1) << 8) + } + + fn chr_offset(&self, addr: u16) -> usize { + let bank = mirror_bank(self.chr_bank(addr), self.chr.len() / CHR_BANK_1K); + bank * CHR_BANK_1K + (usize::from(addr) & (CHR_BANK_1K - 1)) + } + + fn vram_offset(&self, addr: u16) -> usize { + usize::from(self.nametable_address(addr)) & (VRAM_SIZE - 1) + } + + /// A VRC2 register write (`$8000-$FFFF` in VRC2 mode). + fn vrc2_write(&mut self, addr: u16, value: u8) { + match addr & 0xF000 { + 0x8000 => self.vrc2_prg[0] = value & 0x1F, + 0x9000 => self.vrc2_mirroring = value & 1, + 0xA000 => self.vrc2_prg[1] = value & 0x1F, + 0xB000..=0xE000 => { + // Two selects per 4 KiB page, picked by A1; A0 picks the + // nibble. "VRC2 only has 4 high bits of CHR select." + let page = usize::from((addr >> 12) - 0xB); + let slot = page * 2 + usize::from((addr >> 1) & 1); + let old = self.vrc2_chr[slot]; + self.vrc2_chr[slot] = if addr & 1 == 0 { + (old & 0xF0) | (value & 0x0F) + } else { + (old & 0x0F) | ((value & 0x0F) << 4) + }; + } + _ => {} + } + } +} + +impl Mapper for Somari116 { + fn caps(&self) -> MapperCaps { + // The MMC3's A12 filter and the MMC1's consecutive-write check both + // count CPU cycles; the MMC3 is an IRQ source. + MapperCaps::CYCLE_IRQ + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + addr < 0x8000 + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + if addr < 0x8000 { + return 0; + } + let bank = mirror_bank(self.prg_bank(addr), self.prg_rom.len() / PRG_BANK_8K); + self.prg_rom[bank * PRG_BANK_8K + (usize::from(addr) & 0x1FFF)] + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + 0x4020..=0x5FFF if addr & 0xE100 == 0x4100 => { + let was_mmc1 = self.mode() == Mode::Mmc1; + self.supervisor = value & 0x07; + if !was_mmc1 && self.mode() == Mode::Mmc1 { + self.mmc1.reset_shift_register(); + } + } + 0x8000..=0xFFFF => match self.mode() { + Mode::Vrc2 => self.vrc2_write(addr, value), + Mode::Mmc3 => self.mmc3.cpu_write(addr, value), + Mode::Mmc1 => self.mmc1.cpu_write(addr, value), + }, + _ => {} + } + } + + fn chr_phys(&self, addr: u16) -> Option { + if self.chr_is_ram { + None + } else { + u32::try_from(self.chr_offset(addr & 0x1FFF)).ok() + } + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.vram_offset(addr)], + _ => 0, + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF if self.chr_is_ram => { + let off = self.chr_offset(addr); + self.chr[off] = value; + } + 0x2000..=0x3EFF => { + let off = self.vram_offset(addr); + self.vram[off] = value; + } + _ => {} + } + } + + fn current_mirroring(&self) -> Mirroring { + match self.mode() { + Mode::Vrc2 if self.vrc2_mirroring == 0 => Mirroring::Vertical, + Mode::Vrc2 => Mirroring::Horizontal, + Mode::Mmc3 => self.mmc3.current_mirroring(), + Mode::Mmc1 => self.mmc1.current_mirroring(), + } + } + + fn notify_a12(&mut self, level: bool) { + self.mmc3.notify_a12(level); + self.mmc1.notify_a12(level); + } + + fn notify_a12_at_sub_dot(&mut self, level: bool, sub_dot: u8) { + self.mmc3.notify_a12_at_sub_dot(level, sub_dot); + self.mmc1.notify_a12(level); + } + + fn notify_cpu_cycle(&mut self) { + self.mmc3.notify_cpu_cycle(); + self.mmc1.notify_cpu_cycle(); + } + + fn irq_pending(&self) -> bool { + // Only the MMC3 has an IRQ, and only the selected chip drives the + // line. + self.mode() == Mode::Mmc3 && self.mmc3.irq_pending() + } + + fn irq_acknowledge(&mut self) { + self.mmc3.irq_acknowledge(); + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = match self.mode() { + Mode::Mmc3 => self.mmc3.debug_info(), + Mode::Mmc1 => self.mmc1.debug_info(), + Mode::Vrc2 => { + let mut info = MapperDebugInfo::default(); + for (i, p) in self.vrc2_prg.iter().enumerate() { + info.prg_banks + .push((format!("PRG{i}"), format!("{p:#04x}"))); + } + for (i, c) in self.vrc2_chr.iter().enumerate() { + info.chr_banks + .push((format!("CHR{i}"), format!("{c:#04x}"))); + } + info + } + }; + info.mapper_id = 116; + info.name = if self.huang2 { + "SOMARI-P Huang-2 (116)" + } else { + "SOMARI-P Huang-1 (116)" + } + .to_string(); + info.mirroring = crate::mapper::mirroring_name(self.current_mirroring()); + info.extra.push(( + "supervisor".to_string(), + format!("{:#04x}", self.supervisor), + )); + info.extra + .push(("mode".to_string(), format!("{:?}", self.mode()))); + info + } + + fn save_state(&self) -> Vec { + let mmc3 = self.mmc3.save_state(); + let mmc1 = self.mmc1.save_state(); + let chr = if self.chr_is_ram { self.chr.len() } else { 0 }; + let mut out = Vec::with_capacity(HEAD + mmc3.len() + mmc1.len() + chr + VRAM_SIZE); + out.push(SAVE_STATE_VERSION); + out.push(self.supervisor); + out.extend_from_slice(&self.vrc2_prg); + out.push(self.vrc2_mirroring); + out.extend_from_slice(&self.vrc2_chr); + out.extend_from_slice(&(mmc3.len() as u32).to_le_bytes()); + out.extend_from_slice(&(mmc1.len() as u32).to_le_bytes()); + out.extend_from_slice(&mmc3); + out.extend_from_slice(&mmc1); + if self.chr_is_ram { + out.extend_from_slice(&self.chr); + } + out.extend_from_slice(&self.vram); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + if data.len() < HEAD { + return Err(MapperError::WrongLength { + expected: HEAD, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + let len_at = |i: usize| { + u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize + }; + let (mmc3_len, mmc1_len) = (len_at(13), len_at(17)); + let chr = if self.chr_is_ram { self.chr.len() } else { 0 }; + // Checked: both lengths come from the blob, and a plain sum can wrap + // on a 32-bit target, pass the check, and panic at the slice. + let expected = HEAD + .checked_add(mmc3_len) + .and_then(|n| n.checked_add(mmc1_len)) + .and_then(|n| n.checked_add(chr + VRAM_SIZE)); + if expected != Some(data.len()) { + return Err(MapperError::WrongLength { + expected: expected.unwrap_or(usize::MAX), + got: data.len(), + }); + } + let mut cur = HEAD; + self.mmc3.load_state(&data[cur..cur + mmc3_len])?; + cur += mmc3_len; + self.mmc1.load_state(&data[cur..cur + mmc1_len])?; + cur += mmc1_len; + self.supervisor = data[1] & 0x07; + self.vrc2_prg = [data[2] & 0x1F, data[3] & 0x1F]; + self.vrc2_mirroring = data[4] & 1; + self.vrc2_chr.copy_from_slice(&data[5..13]); + if self.chr_is_ram { + self.chr.copy_from_slice(&data[cur..cur + chr]); + cur += chr; + } + self.vram.copy_from_slice(&data[cur..]); + Ok(()) + } +} + +/// Fixed head of the save state: version, supervisor, two VRC2 PRG selects, +/// VRC2 mirroring, eight VRC2 CHR selects, and the two embedded chip states' +/// lengths. +const HEAD: usize = 1 + 1 + 2 + 1 + 8 + 4 + 4; + +#[cfg(test)] +#[path = "m116_somari_tests.rs"] +mod tests; diff --git a/crates/rustynes-mappers/src/m116_somari_tests.rs b/crates/rustynes-mappers/src/m116_somari_tests.rs new file mode 100644 index 00000000..1b19d730 --- /dev/null +++ b/crates/rustynes-mappers/src/m116_somari_tests.rs @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Register-decode tests for `m116_somari.rs`, written from +//! `INES_Mapper_116.md`, `VRC2_and_VRC4.md`, `MMC1.md` and `MMC3.md`. + +use alloc::vec::Vec; + +/// An iNES 1.0 image for mapper 116: every 8 KiB PRG bank and every 1 KiB +/// CHR bank starts with its own bank number (low, high). +fn ines_image(prg_16k: u8, chr_8k: u8) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(b"NES\x1A"); + v.push(prg_16k); + v.push(chr_8k); + v.push(0x40); // mapper low nibble 4 + v.push(0x70); // mapper high nibble 7 -> 0x74 = 116 + v.extend_from_slice(&[0u8; 8]); + for b in 0..usize::from(prg_16k) * 2 { + let mut bank = alloc::vec![0u8; 0x2000]; + bank[0] = b as u8; + bank[1] = (b >> 8) as u8; + v.extend_from_slice(&bank); + } + for b in 0..usize::from(chr_8k) * 8 { + let mut bank = alloc::vec![0u8; 0x400]; + bank[0] = b as u8; + bank[1] = (b >> 8) as u8; + v.extend_from_slice(&bank); + } + v +} + +#[test] +fn m116_parses() { + assert!( + crate::parse(&ines_image(16, 32)).is_ok(), + "mapper 116 must construct" + ); +} + +use super::*; + +fn prg_image(banks_8k: usize) -> Box<[u8]> { + let mut v = vec![0u8; banks_8k * PRG_BANK_8K]; + for b in 0..banks_8k { + v[b * PRG_BANK_8K] = b as u8; + v[b * PRG_BANK_8K + 1] = (b >> 8) as u8; + } + v.into_boxed_slice() +} + +fn chr_image(banks_1k: usize) -> Box<[u8]> { + let mut v = vec![0u8; banks_1k * CHR_BANK_1K]; + for b in 0..banks_1k { + v[b * CHR_BANK_1K] = b as u8; + v[b * CHR_BANK_1K + 1] = (b >> 8) as u8; + } + v.into_boxed_slice() +} + +/// 256 KiB PRG (32 x 8 KiB), 512 KiB CHR (512 x 1 KiB): Huang-1. +fn board() -> Somari116 { + Somari116::new(prg_image(32), chr_image(512), Mirroring::Vertical).expect("valid") +} + +fn prg_at(m: &mut Somari116, addr: u16) -> usize { + m.cpu_read(addr) as usize | (m.cpu_read(addr + 1) as usize) << 8 +} + +fn chr_at(m: &mut Somari116, addr: u16) -> usize { + m.ppu_read(addr) as usize | (m.ppu_read(addr + 1) as usize) << 8 +} + +/// The MMC1 serial port: five writes, LSB first. +fn mmc1_write(m: &mut Somari116, addr: u16, value: u8) { + for i in 0..5 { + m.cpu_write(addr, (value >> i) & 1); + } +} + +fn clock_a12(m: &mut Somari116) { + m.notify_a12(false); + for _ in 0..4 { + m.notify_cpu_cycle(); + } + m.notify_a12(true); + m.notify_cpu_cycle(); +} + +/// The supervisor answers where `addr & $E100 == $4100`. +#[test] +fn m116_supervisor_decode() { + for (addr, hit) in [ + (0x4100u16, true), + (0x41FF, true), + (0x5F00, true), + (0x4200, false), + (0x5000, false), + (0x6100, false), + ] { + let mut m = board(); + m.cpu_write(addr, 0x01); + assert_eq!(m.mode() == Mode::Mmc3, hit, "{addr:#06x}"); + } +} + +/// Power-on is VRC2 mode with every CHR select at `$FF`. +#[test] +fn m116_powers_on_in_vrc2_mode_with_chr_ff() { + let mut m = board(); + assert_eq!(m.mode(), Mode::Vrc2); + for slot in 0..8u16 { + assert_eq!(chr_at(&mut m, slot * 0x400), 0xFF, "slot {slot}"); + } +} + +/// VRC2b: PRG 0 at `$8xxx`, mirroring at `$9xxx`, PRG 1 at `$Axxx`, each in +/// its whole 4 KiB (*Garō Densetsu Special* writes the mirrors); the last +/// 16 KiB fixed. +#[test] +fn m116_vrc2_prg_and_mirroring_answer_in_their_whole_page() { + let mut m = board(); + m.cpu_write(0x8FFF, 0x05); + m.cpu_write(0xA7A3, 0x09); + assert_eq!(prg_at(&mut m, 0x8000), 5); + assert_eq!(prg_at(&mut m, 0xA000), 9); + assert_eq!(prg_at(&mut m, 0xC000), 30); + assert_eq!(prg_at(&mut m, 0xE000), 31); + m.cpu_write(0x9ABC, 0x01); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + m.cpu_write(0x9000, 0x02); // bit 1 ignored on the VRC2 + assert_eq!(m.current_mirroring(), Mirroring::Vertical); +} + +/// VRC2b CHR selects: A12 and A1 pick the select, A0 the nibble, and the +/// VRC2 has only four high bits. +#[test] +fn m116_vrc2_chr_selects() { + let mut m = board(); + for (lo, hi, window) in [ + (0xB000u16, 0xB001u16, 0x0000u16), + (0xB002, 0xB003, 0x0400), + (0xC000, 0xC001, 0x0800), + (0xC002, 0xC003, 0x0C00), + (0xD000, 0xD001, 0x1000), + (0xD002, 0xD003, 0x1400), + (0xE000, 0xE001, 0x1800), + (0xE002, 0xE003, 0x1C00), + ] { + m.cpu_write(lo, 0x3A); // low nibble A + m.cpu_write(hi, 0x15); // bit 4 ignored: high nibble 5 + assert_eq!(chr_at(&mut m, window), 0x5A, "{lo:#06x}/{hi:#06x}"); + } +} + +/// MMC3 mode: a stock MMC3 with its IRQ, which drives the line only while +/// MMC3 mode is selected. +#[test] +fn m116_mmc3_mode_and_its_irq() { + let mut m = board(); + m.cpu_write(0x4100, 0x01); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x07); + m.cpu_write(0x8000, 2); + m.cpu_write(0x8001, 0x33); + assert_eq!(prg_at(&mut m, 0x8000), 7); + assert_eq!(prg_at(&mut m, 0xE000), 31); + assert_eq!(chr_at(&mut m, 0x1000), 0x33); + m.cpu_write(0xA000, 1); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + m.cpu_write(0xC000, 1); + m.cpu_write(0xC001, 0); + m.cpu_write(0xE001, 0); + clock_a12(&mut m); + clock_a12(&mut m); + assert!(m.irq_pending()); + m.cpu_write(0x4100, 0x00); + assert!(!m.irq_pending(), "VRC2 mode: the MMC3 is deselected"); + m.cpu_write(0x4100, 0x01); + assert!(m.irq_pending(), "its IRQ state survives the switch"); +} + +/// "All three simulated chips have their separate registers": each chip's +/// banks come back when it is selected again. +#[test] +fn m116_each_chip_keeps_its_own_registers() { + let mut m = board(); + m.cpu_write(0x8000, 0x05); // VRC2 PRG 0 + m.cpu_write(0x4100, 0x01); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x09); // MMC3 R6 + m.cpu_write(0x4100, 0x02); + mmc1_write(&mut m, 0xE000, 0x03); // MMC1 PRG + assert_eq!(prg_at(&mut m, 0x8000), 6, "MMC1: 16 KiB bank 3"); + m.cpu_write(0x4100, 0x00); + assert_eq!(prg_at(&mut m, 0x8000), 5, "VRC2 again"); + m.cpu_write(0x4100, 0x01); + assert_eq!(prg_at(&mut m, 0x8000), 9, "MMC3 again"); + m.cpu_write(0x4100, 0x03); + assert_eq!(prg_at(&mut m, 0x8000), 6, "mode 3 is MMC1 too"); +} + +/// MMC1 mode: PRG mode 3 (switch `$8000`, last bank at `$C000`), 4 KiB and +/// 8 KiB CHR modes, and the control register's mirroring. +#[test] +fn m116_mmc1_mode_banks() { + let mut m = board(); + m.cpu_write(0x4100, 0x02); + mmc1_write(&mut m, 0x8000, 0x1F); // control: PRG mode 3, CHR 4 KiB, H + mmc1_write(&mut m, 0xE000, 0x02); + assert_eq!(prg_at(&mut m, 0x8000), 4); + assert_eq!(prg_at(&mut m, 0xC000), 30); + mmc1_write(&mut m, 0xA000, 0x05); + mmc1_write(&mut m, 0xC000, 0x09); + assert_eq!(chr_at(&mut m, 0x0000), 5 * 4); + assert_eq!(chr_at(&mut m, 0x1C00), 9 * 4 + 3); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + mmc1_write(&mut m, 0x8000, 0x02); // PRG mode 0 (32 KiB), CHR 8 KiB, V + assert_eq!(prg_at(&mut m, 0x8000), 4); + assert_eq!(prg_at(&mut m, 0xC000), 6); + // 8 KiB mode: CHR 0's low bit is ignored, A12 picks the half: + // (5 & $1E) | 1 = 4 KiB bank 5 = 1 KiB bank 20 at `$1000`. + assert_eq!(chr_at(&mut m, 0x1000), 20); + assert_eq!(chr_at(&mut m, 0x0000), 16); + assert_eq!(m.current_mirroring(), Mirroring::Vertical); +} + +/// "When switching to MMC1 mode, the serial shift register is reset": three +/// stray bits written before a switch away and back do not corrupt the next +/// five-write sequence. Re-selecting MMC1 from MMC1 (mode 2 -> 3) is not a +/// switch. +#[test] +fn m116_switching_to_mmc1_resets_the_shift_register() { + let mut m = board(); + m.cpu_write(0x4100, 0x02); + for _ in 0..3 { + m.cpu_write(0xE000, 1); + } + m.cpu_write(0x4100, 0x01); + m.cpu_write(0x4100, 0x02); + mmc1_write(&mut m, 0xE000, 0x02); + assert_eq!(prg_at(&mut m, 0x8000), 4, "a clean five-write commit"); + + let mut m = board(); + m.cpu_write(0x4100, 0x02); + for _ in 0..3 { + m.cpu_write(0xE000, 1); + } + m.cpu_write(0x4100, 0x03); // still MMC1: no reset + m.cpu_write(0xE000, 0); + m.cpu_write(0xE000, 0); // fifth bit: commits 0b00111 + assert_eq!(prg_at(&mut m, 0x8000), 14, "the shift continued"); +} + +/// The supervisor's bit 2 is CHR A18 in every mode. +#[test] +fn m116_outer_chr_bank_in_every_mode() { + let mut m = board(); + m.cpu_write(0xB000, 0x04); + m.cpu_write(0xB001, 0x00); + m.cpu_write(0x4100, 0x04); + assert_eq!(chr_at(&mut m, 0x0000), 0x104, "VRC2"); + m.cpu_write(0x4100, 0x05); + m.cpu_write(0x8000, 2); + m.cpu_write(0x8001, 0x21); + assert_eq!(chr_at(&mut m, 0x1000), 0x121, "MMC3"); + m.cpu_write(0x4100, 0x06); + mmc1_write(&mut m, 0x8000, 0x1C); + mmc1_write(&mut m, 0xA000, 0x03); + assert_eq!(chr_at(&mut m, 0x0000), 0x100 + 12, "MMC1"); +} + +/// Huang-2 (PRG-ROM and CHR-ROM both 128 KiB): the MMC1 PRG register is +/// written one bit to the left. Huang-1 is not. +#[test] +fn m116_huang2_shifts_the_mmc1_prg_register() { + let mut m = Somari116::new(prg_image(16), chr_image(128), Mirroring::Vertical).expect("valid"); + assert!(m.huang2); + m.cpu_write(0x4100, 0x02); + mmc1_write(&mut m, 0xE000, 0x06); + assert_eq!(prg_at(&mut m, 0x8000), 6, "written 6 = bank 3"); + let mut m = board(); + assert!(!m.huang2); + m.cpu_write(0x4100, 0x02); + mmc1_write(&mut m, 0xE000, 0x06); + assert_eq!(prg_at(&mut m, 0x8000), 12, "written 6 = bank 6"); +} + +#[test] +fn m116_state_round_trip() { + let mut m = board(); + m.cpu_write(0x8000, 0x05); + m.cpu_write(0xB001, 0x03); + m.cpu_write(0x4100, 0x05); + m.cpu_write(0x8000, 6); + m.cpu_write(0x8001, 0x09); + m.ppu_write(0x2123, 0x44); + let blob = m.save_state(); + let mut n = board(); + n.load_state(&blob).expect("round trip"); + assert_eq!(n.save_state(), blob); + assert_eq!(n.ppu_read(0x2123), 0x44); + let mut bad = blob.clone(); + bad[13..17].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(board().load_state(&bad).is_err()); +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index bc41168f..e1254e00 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -141,10 +141,11 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // `docs/compatibility.md`: a local dump that boots to its title, named // by title and `Nes::rom_sha256`, pinned by a boot snapshot in // `external_extended.rs` and a committed screenshot. 182 is a - // duplicate of 114 (the 114 page) and shares its model. A separate arm + // duplicate of 114 (the 114 page) and shares its model; 116 (SOMARI-P, + // three simulated chips) is in `m116_somari.rs`. A separate arm // so each release's batch keeps its own rationale beside its ids. #[allow(clippy::match_same_arms)] - 114 | 165 | 182 | 197 | 215 => Some(MapperTier::Curated), + 114 | 116 | 165 | 182 | 197 | 215 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -254,7 +255,7 @@ mod tests { // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, // v3.2.0 (`mmc3_boards_ext.rs`). - 114, 165, 182, 197, 215, + 114, 116, 165, 182, 197, 215, ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index afd8c1d0..2e9a97c7 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1517,3 +1517,31 @@ fn extended_m165_fire_emblem_gaiden_ch() { "extended_m165_fire_emblem_gaiden_ch", ); } + +/// One START at frame 400 starts Green Hill Zone act 1; the f1000 checkpoint +/// is gameplay (Somari, the HUD and the scrolling zone). The game cycles the +/// supervisor through all three chips while it initialises. +const M116_SOMARI_PLAY: InputScript = InputScript::StartTap { + idle_pre: 400, + idle_post: 0, + free_run: 799, + checkpoints: &[1000], +}; + +#[test] +fn extended_m116_somari() { + check( + "mapper-116-SomariW/Somari (SOMARI-W) (Unl) [!].zip", + M116_SOMARI_PLAY, + "extended_m116_somari", + ); +} + +#[test] +fn extended_m116_kart_fighter() { + check( + "mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].zip", + DEFAULT_IDLE, + "extended_m116_kart_fighter", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_kart_fighter.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_kart_fighter.snap new file mode 100644 index 00000000..13237f1e --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_kart_fighter.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].zip +rom_sha256=82e8224ecb8707398d2d4db69ae5735a921b4389f1efae9f3eadf78f54f77c74 +frames=600 +fb_bytes=245760 +cycles=17838521 +audio_samples=439524 +audio_fnv1a64=946a8b87cc4dd496 +checkpoint f600 fb_fnv1a64=925de2c9c67df433 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_somari.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_somari.snap new file mode 100644 index 00000000..c2c72e91 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m116_somari.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-116-SomariW/Somari (SOMARI-W) (Unl) [!].zip +rom_sha256=1938c4c9fc2e1de27eeee9ec4b22ad1144f13e38a22503c3c464ea9130552caf +frames=1200 +fb_bytes=245760 +cycles=35706823 +audio_samples=879798 +audio_fnv1a64=aa3fb3cf0787772f +checkpoint f1000 fb_fnv1a64=bd784cd0915b21dd diff --git a/docs/compatibility.md b/docs/compatibility.md index 5b968a07..8016d3e5 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -376,6 +376,8 @@ screenshot of that frame. The dumps themselves stay in the gitignored | 114 | Aladdin (SuperGame) (Mapper 114) [!] | `5489244538c94653f766bd80f07b2aa684ad139bddea68f40b60a0473c9286e2` | 600 | `screenshots/external/mapper-114-SuperGame/Aladdin (SuperGame) (Mapper 114) [!].png` | | 114.1 | Boogerman (Sugar Softec) (Unl) [!] (GoodNES labels it 215; staged with a NES 2.0 114.1 header) | `ba2d65f240d8f97c7b77973042d8141cf15116ffc3d73caed450c21e08fd54a2` | 600 | `screenshots/external/mapper-114-SuperGame/Boogerman (Sugar Softec) (Unl) [!] (NES 2.0 114.1).png` | | 182 (as 114.0) | Super Donkey Kong (Unl) [!] | `67d06173c4b8239d46c84335591216b1adfa38344107405ec88c58327217a361` | 600 | `screenshots/external/mapper-182-Hosenkan/Super Donkey Kong (Unl) [!].png` | +| 116 | Somari (SOMARI-W) (Unl) [!] (START at 400; screenshot = the f1000 checkpoint, Green Hill Zone act 1 in play) | `f5b2998c853ddb5c495e96ac31e4abfff8f1da8367b9ae074efbcb7fdfee452c` | 1200 | `screenshots/external/mapper-116-SomariW/Somari (SOMARI-W) (Unl) [!].png` | +| 116 | Kart Fighter (SOMARI-W) (Unl) [!] | `8fd609f49be61a9a82233a175a0a32a3d43c722d31a7a4115380f4b7f1e1615f` | 600 | `screenshots/external/mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].png` | | 165 | Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch) (START at 700 and 1101; screenshot = the f2100 checkpoint, the intro text over the sword) | `0d396a7615b2499edd0997628b733236d88b002e364833edaf166e0a1c96ecf9` | 2202 | `screenshots/external/mapper-165-FireEmblemCh/Fire Emblem - Ankoku Ryuu to Hikari no Tsurugi (Ch).png` | | 165 | Fire Emblem Gaiden (Ch) (same script; it holds its title) | `621a4d4ee54524e9e62cce97adcba5b332c06bd1e4174a37455f474e747cd35e` | 2202 | `screenshots/external/mapper-165-FireEmblemCh/Fire Emblem Gaiden (Ch).png` | | 197 | Super Fighter III (Unl) [!] (submapper 0, PRG CRC32 `C333F621`) | `dfb4771a36dbcb05a2753a95810a11164665282ccac4cf7ee52d26d2ed6e5e4f` | 600 | `screenshots/external/mapper-197-SuperFighter3/Super Fighter III (Unl) [!].png` | diff --git a/docs/mappers.md b/docs/mappers.md index 36fc5f9a..cef2f91d 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -838,7 +838,7 @@ path), were all CAUGHT by the unit tests or `roster_boards.rs`. Two needed a new test first: the bus's floating-read call, which no mapper-level test can reach, and the PPU's unfolded `$3xxx` path. The record is in the v2.9.6 plan. -### v3.2.0 — MMC3-derived and multi-mode boards (`mmc3_boards_ext.rs`) +### v3.2.0 — MMC3-derived and multi-mode boards (`mmc3_boards_ext.rs`, `m116_somari.rs`) Written from the vendored NESdev pages named in the table; no reference-emulator source was read. The boards live in a new module, @@ -854,6 +854,7 @@ boot snapshot in `external_extended.rs` and a committed screenshot. |------|-----|--------------|------|-----|-------| | 114 | 0/1 | SuperGame / Hosenkan MMC3 scramble (`INES_Mapper_114`) | Curated | MMC3A (alternate) | Register addresses and `$8000` indices permuted per the page's two tables (submapper 1 is Boogerman's pattern). `$6000` (`M.S. BBBb`) is an NROM override of both PRG halves, NROM-256 when S is set; `$6001` bit 0 is CHR A18. Both answer whatever `$A001` says ("not connected to the MMC3 clone's WRAM interface"). The page gives their mask as "probably `$E001`"; that mask is used. GoodNES labels *Boogerman* (Sugar Softec) mapper 215, the assignment the 215 page says was withdrawn; it never writes `$5007` and boots only as 114.1, so its D23 record uses a copy with a NES 2.0 114.1 header (`Nes::rom_sha256` excludes the header, so the identity is the dump's). | | 182 | — | (`INES_Mapper_182` → `INES_Mapper_114`) | Curated | as 114 | A duplicate of 114: the 182 page is a redirect, and the 114 page says Hosenkan's 182 games write 0 to `$6000`/`$6001` once and never again. Dispatched as 114 submapper 0; the full 114 model is kept rather than the "no `$600x`" simplification, since the zero writes leave it in the MMC3's own banking. | +| 116 | — | SOMARI-P, Huang-1 / Huang-2 (`INES_Mapper_116`) | Curated | MMC3's, in MMC3 mode only | In `m116_somari.rs`, not the MMC3 module: a supervisor at `$4100` (mask `$E100`) selects a simulated VRC2b (0), MMC3 (1) or MMC1 (2, 3), and bit 2 is CHR A18 in every mode. Each chip keeps its own registers (the project's `Mmc3` and `Mmc1` as register files, the VRC2 written here from `VRC2_and_VRC4`); only the selected one takes `$8000-$FFFF` writes and drives banks, mirroring and the IRQ line. VRC2 PRG / mirroring / PRG answer in all of `$8xxx` / `$9xxx` / `$Axxx`; its CHR selects power up at `$FF`. Switching INTO MMC1 mode empties the serial shift register (new `Mmc1::reset_shift_register`, which leaves `control` alone). Huang-2 (MMC1 PRG written one bit left) is picked by the page's heuristic, PRG and CHR both 128 KiB. Not on the page, chosen here: power-on in VRC2 mode (the page's note that *Somari* relies on the VRC2's power-up CHR value implies it); deselected chips keep receiving A12 and CPU-cycle clocks. Not modelled: the VRC2 `$6000` microwire latch and submapper 3 (one sentence on the page, no registers). All four staged dumps (Somari, Kart Fighter, Garō Densetsu Special, A.V. Girl Fighting) cycle the supervisor through all three chips while initialising and then run in MMC3 mode, so the boot snapshots do not reach the VRC2 and MMC1 bank details, which are unit-tested only. *Samurai Spirits (Rex Soft)* and *Chuugoku Taitei (Sachen)* are labelled 116 by GoodNES; `INES_Mapper_014` names the former (the 8-character Rex Soft version) as mapper 14, and neither was tried here. | | 165 | — | MMC3 + MMC2 latch (`INES_Mapper_165`) | Curated | MMC3 (Sharp) | The page is Disch's notes only, so three readings are this model's. (1) A 4 KiB CHR page is the MMC3 register over 4 (`$0000`: R0 / R1, `$1000`: R2 / R4 by latch), page 0 being the 4 KiB CHR-RAM and page n reading ROM at n x 4 KiB; `$8000` bit 7 has no effect. (2) The latches are the MMC2's (`$0FD8` / `$0FE8` exactly; `$1FD8-$1FDF` / `$1FE8-$1FEF`), switching after the read. The notes' remark that the game reads "`$xFD0` or `$xFE0` via `$2007`" is not taken as a trigger: triggering on all of `$xFD0-$xFDF` drew a stray bar before every intro line on the Fire Emblem translation, the MMC2 triggers draw it clean. (3) 8 KiB of work RAM by default ("SRAM enabling ... as on your vanilla MMC3"): without it *Fire Emblem Gaiden (Ch)* draws its title as garbage. CHR reads are impure (`chr_reads_are_pure` is false). Curated on its D23 records under this release's rule; under v2.9.6's page-only rule a Disch-notes page would have been BestEffort. | | 197 | 0-3 | MMC3 with 2 / 4 KiB CHR banks (`INES_Mapper_197`) | Curated | MMC3 | Modelled as the page's WIRING, not its bank table: the MMC3 sees PA10 = PPU A11, PA11 = GND (sub 0, 3), Vcc (sub 1) or PPU A11 (sub 2), PA12 = PPU A12; its CHR outputs A10-A17 drive ROM A11-A18 and PPU A10 drives ROM A10. The page's tables for submappers 0-2 follow (unit-tested row by row), and `$8000` bit 7, which the page does not mention, still swaps the pattern tables as the MMC3 would. Submapper 3's `$6000-$7FFF` register (`S..P`: PRG A17 = P when S is set) is on the WRAM interface, so it is taken only while `$A001` allows a write. PRG, mirroring and IRQ are mapper 4's (Sharp). iNES 1.0 images are wired as submapper 0; the page's submapper 1 / 2 games are identified by PRG CRC32 there, which belongs in the per-game database, and no dump of either is staged. | | 215 | 0/1 | Sugar Softec UNL-8237 / UNL-8237A (`INES_Mapper_215`) | Curated | MMC3 (Sharp; the page names no revision) | `$5007` (mask `$F007`) selects one of eight scramble patterns (0, 2, 5, 6, 7 are the identity; 1, 3, 4 per the page's tables). `$5000` `MCS. BBBb` is 114's NROM override plus `C`, the 128 KiB mode that replaces PRG A17 / CHR A17 with `$5001` bits 4 / 5; `C` is applied after the NROM override, since the page describes it as a substitution on the A17 line both drive (the order is not stated). `$5001` is the outer bank (sub 0: PRG A18-A19 = bits 0-1, CHR A18-A19 = bits 2-3; sub 1: PRG A18/A19/A20 = bits 0/1/3, CHR A18-A20 = bits 1-3), power-up `$xF` (taken as `$0F`) and restored on a console reset; `$5000` and `$5007` power up `$00`. No WRAM unless a NES 2.0 header declares it. The page's index tables were checked black-box: pattern 1 (the *Super 808* menu, then *Aladdin* selected from it) and pattern 4 (*Mortal Kombat 3 Extra 60*) both run, and pattern 1's index table is the inverse of 114 submapper 0's, as printed. The "alternative outer bank registers in the `$6xxx` range" some multicarts carry are not modelled. | diff --git a/screenshots/external/mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].png b/screenshots/external/mapper-116-SomariW/Kart Fighter (SOMARI-W) (Unl) [!].png new file mode 100644 index 0000000000000000000000000000000000000000..418aa24522e8bfa825cc1890d2532267aec2dc4b GIT binary patch literal 5070 zcma)=cT|(hw!ps>dKZL9FG`W#MFaw&N<2#*2!YWve zuk`%1oA`!)^h^-~;#Vs@$L|rJU{{uUX@yncWMNl&@)WP#883F0L-?v46yx@NW@D$V zC2wNFs7|vDH_Rd)9vDts?`Zl>8`{v-)Q{TjoR5+d19BJ6llTE_BpLwjbpilq0tkRH zLjW~SW&jQ21n_Dw0PCg(0Nwx5;L6QnK&ktPBhdLaeRj(rHnPS8=r3;nJahZ->DAB0 zh0cZhUV(ud)VM*sN-5yIBw2kSnZD_8&~YHPJiw!B!GgYTz+r#qh8yJ%yr4`1W{dct ztv+gFBXkCog*DSD0^juwxw>SEGuwi2y8Y&ZzlWMU-*39sroLbf8ary7cn4Z21xgmL zVa?$yl_FS)?E(7uGZEgw6L93_!V$pC2>_#N-2Y)F<>YptsW>iXfB!Q|;;Z3tY;W`7 zi-In!0ni<$B6vbF`+g+WCk(y`yph8s0cIYeF2WNh}p|6bf@e4hCM^aia!`VZ1d z%Q@lW7 zS0)=4oE4JuoXDW+1Y8|DB>2$6a0Hq}$L%?q9U+tp4R|NI>*@#ix%77ZFjaE@YJ%yq zN&Y58F9c`Pnt!+KuD%ISDXbM06Fbh2LwqN6RJ}nPGFc`Q=f;)A&YqRJdoTH8aKvNQ zoR}}0!w3l!5-;SxHjUWGfpJq-CF1I$4$W_woJZ?}lalElGK^NM=!2Xx4RE123v zd%n5U1Ea&kwzf$l0T6ae0B_+N&4;5z1uuWX)P2i?FF|ydrDbE~#I1NjA;|P5J)x>Cc5{3^BGP4lJaI9< zJt3F`(%Vn^<@9BE&RUDz*R;OnPUv#ZSvkEpe@}Z{0{`L7V!E7sSd(+(R!iU*6nO%E zo~l0=F=6;4l9eHkzJuY$@CJyKb#Q_}#Nx>!bB`^q7(X zO0My=t`GRig;W&B(QHV8ZB=tBr)b;Cvo*K zRPG;M>^P8jx=o!d)shyV8RlgAASmJHyuzne0&~t*+ag?TRyyu(GR4rIMt=?^3=4y2 z=@V+$xRQ!0J$&L5s!qiGeGVg=Y|xFn5#v-w{$;;#xk)LL`r^qW7!4M4Fcny)MEGg=*J zo5~Y0+KB?MAw(ohAxOk+bgXwD$+Fj}9!HL6BFizdt$m__wjKH^I);hN=P{ zQ9dH1r4XH=k}HC-6>K(Kb2Gnpt7_hp%Q!3pK9N!{jV5NEC^b}U5~B?4%!;)K(wi%$ z9wPdkeXmOz2;yB3a#Vt0&N|-P0D8GMa2zmU>S3Gt$}RCHwdTpeE=t++XLrWT<~%WD z1A3wrB`I*HZMZcnp4{l69KyEi^{$hb|J5PqxuU(6fDSp|8SVKWx_RJ(X7DqS2Rm!( z%7VOdHQ#t$+s|DV$@qlZP3nS`#4HbTI|#RQ8c5MT&Q52Ru;LM|prU%FbPK=9g)Tp%AbnjpGCEzSc3s#xpQ%TK z%7w?HKPLtsP0#o29(UO?yZx8L~caHZxY73zECYJGr-Z^nAZ$TBPvkp(YFo zq9$C~EwTU%UwM~33`wm3KUD;y(keovv@%d^eTJ&GbeklMUJ`J*FpVB^E)4Q+<1XHhq0TbduV0?iT%>hjA!{jVRCa z0nD>fOz!6uBVV~yUQB8&HhdX}dLTvN64Tsqp*B7!SvVK@E}PY3YQJHvztNxzx>gD( z7~k*eEG)1MA)Qn}pbAV@&rNRyo7`?E7CM*_9r%yIRkp9v1BtcvQ)NR^I>5+Ta$OY@B6ybuD$mTmdWVAe5n{G z)%m|Ehb%GHhC}I7X5skapfap;46|8$UeuRAi->4^(7HENOh}Z*yDpygFI9Yw2m8d;>09VSfhx7&9$ZcYH?U7+bD7 z)`3f_7i!XFQ+A?+su>ax(*)^XA09SXR;YtqIBc(HJ0NOnS!W(i3j`x)%RBY?F2Jxm z_j5QY7+=z~l~M2#7^WZpKHEg%e6hQ^j^{n`_UdnY7_}a#xNtIfumLn^woZ*jaD2{8 zhHJIRs+I4lx)qWsWwQKYXVoo-g1n|IRy9k!1l7ENQz`)u&fK#>ws?Nhtl!H}mcR^^ zFdO(Y>ruw$PX*sULy_H-myG3Tz~u?xc|vn6SxmYkF!z?YE4HXsra5X|BPYu-_ZF@5 z(65cRpYUxJXkhJ1zVmK?gE-<%kr=A-?pBK11tOweu`r9fdG#0p-uyIF&KbPtC!yv zfWZ;e4LRI*8m+IGC_Cji0A_pIn?Vs%LoT5;0bBYE~61 zw9zHE zP+R^~JD$59qy^i~NCtyi(|GNDX^sz! zt{YVHa6Z#$ZImdWQxxDIgd^#RxKDpdYAjxNTeeA-~8A6*aQj$ zZAm<3Cko-_qYvlIUSLDxL2zekIJsE3X^NTOQTZRJZ0=EcX|&SXzF)j}s2(;YI>KFZ z*#~-8UJD!`!3;JQm}#xPMx%w+xT*o~!JUEplMFsZKx_E!4Y8>0#2$?uVKO#U=VR}X zc|m^IjzeInxtNHOD6l*k8hwwaiRB-H73?-+H3j(_i(sXknv3aLX18@VfH5YB+Fe1K zHOnA@r4%SYCm#ZK7-nt*!$&Fg5JWtP6Coq{-`HCsgr1x7ZK+^(0z({44({jVxIst2 zF!)@=u1e5>pMfmUP{M__+Pax*$uB(1Zh!U)FAmEF_o}jGRZK?Vq0gkRb7OhZSbTunSaz!qqAN1F?E2neHCjsBxCLtll$&3H`7WX_z@=tB76OELbgsU|H zPl>U-Jr8;HmDus>K;-nvni+;#^Dp(K9!8&^>TshBfyUp0znpw}i~GS4sAI31otfkP%gkaebXLjs?1^ldS zAuas@E><>f zp&^0IugzI}$Qyi#>)B6<2of4RrIK_1cP_kP8C?0p!c{*P-#D;17s7w0SRf>5d|M_k zUYHR6Ml`IvD)12`9zdUXtqTUj4gPig{3`*nP-d!1N?SZt1Xh4lk&QEUDfmEHSIu)2Tk8~R#3#I`O>}MZ! zifqlLto~c>;-@|Rp$$lE7FI2#M7u>o%I|~Z0|OAHC2pwINfr(TdVgB;G`{Cj9priQ zXm`Cm>Z=;#Cb{l5pdFo(>*HYnykSI9mayStX}`~+4<7TC`UX|}+>MHxdH4swT(ccJ z?PU3aDA-##;8?o^>#_6Vgg8bR1zZSjrBg{tu)d(@d}{SvGuZT+zS!v}7w~p%_;ipL zUYE5MB!d2E_9_B&O-fGeoBPLGg+6Ov4r5;{s_nFtNiyY@NHkp*;vQQzFZDa{k{TCt z)IW{~E`QJ9AO(x^hAa73UJ4i#!B0|xuEo2?ubZTQzf#v^*D=V$BnW}oMeQd>K6wEI zu&pzv;;u)i`vXpu7;eF3#S12e4xt50u06@OIz#XQbAIlB+`q~P8B0ifHCixi z^KSbo^>)=LZ(QL1Bjo!&_bQQrfHE#-CU73EI# z%ZpEHof14Mery2KH`%;QLNzun3=QjT1T8WrR^iJnH46nVQOlKQVCY5ax7F3`qz#K# zJ&Y=18nG1-ieO_l;kw)DeT$P3X9FmYUs2NhFdRadXaOcvJ%1V{RT!nw%v4ldNsp3@ zlE$Fg?)m@aAc70Kf zp*1(Kuq(cQK0u}kR&)D)K^IQ;i<>0~KpB4O*lUvn4+||qnpfNxiF$XZ+X>yUYj_;4 zU*lq+7>y?jFSa{1y60|?7C`KrI^SGnTbTq~<5Z;~Uhxu4o42Z_X!}U5qrex|J_XSr7^8nFw*}g*9EV}qJ>Q2VGxj@?-?PJ={)nAI{ z#ihU8!kC&?BIl3Db{}%TU0LY$GX?Y~^4K+@bLIGu(iA9gKYpr1nOT*BV8~*j^+kqO zHg!X6T~N6#i1MtLfn{hE{@4OF0ci zm#;l5Q4bp*?~oKYd4`qwX6@^q`;HcLv_6dE%L|Mw%w0dbsu!2weN}GYg_RE8tt-Ii z?vBE7MQKm`eF?*t)EYM@Kc1Vcws-Ju<=F8~oPs2fWC&I!87GxHow|*J5X(6i_`45) zKFchfKEWDG+%INXtYvtA*&fU&k69Us#I3*Bg0o+T1NCi4{fU3ZkDmP@5-ciy+wn1B zjz*E6>8;lzQ#%9ZB00^6i7!MpZQ{u@_hPkviMgEE&}FM#10?^MP2_L4PwWg4ZJzRk znUiPEaedak;PGx2AUNVOV{5UJV?D|%L$Z^I9OS+^!}&YR&&>dmTJn4{Tjbao_u@ws zZG>;}{UPNnwFA#dxW^Z}ZVJ+Q?rNTRp?xg*ZO*Es9WdLN;*D_R+gE_v`tpjD+Jq}$ zS?{r+O25&(81v@n&dQ_wZs$qHovYilT=V=)8(8Dmg`RS(N`Tt zAMmN$-p2BEoVN37I(;0>DpMecE=PoUe<4mP$xRRCnEzg*gef-u= z+id(uI{Z8+k@p?{Fr51IkEELnEbqjd0+-q1>iEx_q9*IXm7waS{xXQ);Uh1Md@n$M%8i9MRgE zg#Oi@lSjTNHkQh28X~aPF;Y9O992*w8>e4>fLiVS;OBs&Qd&w6Ava>@MGC$iObt1Z zW+xxLhai3`+R~+gIqvJ+I)%Ulqf|r-cV~lOMhX5iMW+ z-pZYk3V@n}M@LJA&8B(Z!`Y@1NCB+7d)ysxyf_&Whz*qQ7=V4^SH2}z3l;yhQWm7^zf_}uscU1yJ=fK1f-padBB$MT(*V6jiD)Q;83H-n9m%SO1lc(;H`yufx>Ww8;ogOsY@cqFm7bN+$b-0IMLIoU!` zw`J4=EdETb7(e7TrR@_@ayc6Vd?ReGBGJ&!Z*!F zVyfGx$Uh^Z7WhtW0TJd$;&rOu{%qK$)z2A9x%@ZJY1U;kIw&ChF`d2gF;kw2_wrae@@ambjf!-uJV7k9$R?d&^g3Y=l!dmj&Bqz)nz9xTtn+J{GQe^Cl3`qh~9x zDR=t0KSl$0t>R_xwW$q>Om*ymcygWC!_-OsM%y`$9Dt_b_^H(5Du3DZdQ;SvAU^w2 zp9Q?-t^x0SB-WzxSky~FfA;J;iB#Y1jT@IFX+~~o>am>_mUrYrwWDLn*Pk^oY4{x~=}ulzyjhHM-xucIARx2k zjkbH3k|?yt&_CJ+wxq{d`Ci_Mm3zZONQc?W`(|0L#|~?sRV%dn)&^T%MPKi#o6W-d z{kB>;QFgqD$2bm}JGhJ}q@y5T4=Q47C+o--O0P4T5RRYS-QcXG71qRS5gjFv8{D9o zG`5oge=Ue_xfBw&P1IuBSzt3~W$PpdZVMvjq>XwaDs`jBHaKH?8bAN&16u99-(rW> z=M@%}9a14uGA;BVV?sl6z|@5#PyY9LHe{!e7a9`Cxy#Bw_=s9!HZ0^i+cZ$dSFbNeH9 z4qdo3e3I_=87$zMpJS3532-L~boI21ZC;c!&hNk>E5YiPQ{KwBYllq#0KSQKJas77 z;RL0p15s3$z)r!yK3G0+<FVS z$HIcpNoEj~6tvUkVClNo$41J=%22m-2N zd|3My%Ap~w&v_qTthL#@8Z#aVe;4Gxk z)k5hT_=x&6$*ty!jo}o)GsGzgocY9%d!&goLYTqvOD;^&aY|Z(oX!E`Ow87yigIk9 zE>?{7_4L{dH-q-&@a})BMlC%2aD>^U;bK#AEbli&X@)=DPNHs6f$P*9rJGWYZ#dS{ zAbIiaHRMvnPI}y;axNF+;h}=+so)wLR=RY@>^f1w=c!S^`or^mZu&TOSX-FT2X0^q zwP_Zsv5dVDz8T+hw)%;ML>uF%pv-p8o&Jl15%fRBOV<(Jdye5h5(TZDz@7*ev^%Neh-6?Et}H z`-=ViZ_Y#nk6jimZ6}%q?0-%|oZ$>PL5tZakhjO(9Wx3!GExI;RFYwmRYItmMdf^% zqow!a%blRe65I1P9t8mw?f+^Rhxq%ro$C8XQG1+>SD4N|2H4FaVcSFhLN5DABgs)? z{j8rK`t>QC0b)NY-wCTctTjd3FJCCwsJ%ZCuU`lszU5L7u zgaRih8rc?!np##gy%&sPaJOgbyWm5DEE_+!X0`er6T$0%7rc?r=-jl3ySZC%%}OEL zrD*1c3A^ebW5owgM}dqE4m82gm9(vA;$alBQw8TX!#5vGv!RHe3^E!DP^WL3J;OOu z&GHfHDqG~UW;gfs{mq`e(1yAvv^F;bLdi5(SnNC8)&to{HHhhcHf6 z#KAr)ATa4P3s#N^qh&onUAhxF>Ujxp(Rk*7@DfEJD7(=Yo^jh=Vi(9ua0OmZGbo9iT0YZ8L&4Mp^!W#EIj$5la`*uHJtguIcPRrA~K37?- z@A{`3wBIms?_3hwMTxVJf~f;z1^rGgVQ7EIPLDyCggERLvb9Bw4c(Y{aQ}DwTK!Qi zFFB?c-&9kjxXbWny_oj!>+_P$B%*RS*r0FMmrf!tVE;r&M+jjVT0c6~(dj`un{#l? z-W6TYERh|R#ZQx#G%)Drnv%gq!tN@d0wy)z3Yd+<2!{NA6veY9$7iq`6taUa)3h0o z`>Vtplo#MtO3=~YnwoY0xA>}JSe~^&A2FSv-`WK}KE?B-j%_;P7cQ?g_juQl@2MOG z|H)3GgJ9ojErS3-AE&+s4{cp6p)QcPK5llT#%%&SQ2IW)FE2`dxk0)xMga^lbGgAobEk0;q~2NUd&jNHv+5m2KP+kmzIV&QJV#oBCS1w9 z@A+o4ktr|-d0YWRHz}QamvGn!f79TB`1y~TJ@_a6rqTpyLPS0!HP>8mxv5so+PWQGVkR85;>J=IJiHmo!`x-&!cDQ{a!9P+R_;x{8^yL?O}<{`3|cG9 zc;P%)o@=l;&N6rAEn<8@HJi(MGMy*gtg`1g&lW&xc7lOx9L~KOy?9vk5GY6f>p{)| z`p3I!WwKZkxE{av%)5N%M?p@i*-EcUjI;tliw#uq%O#usfY{b=bJW}C^x7zKX&A;8 z69eEIm$$5u`buRUlZx+0+k%0dS8p5)5--yog`J>spP=JSRLXSh z=z>;mF*T95(Ygc_O1|3 z7%qUb`SGZP!il9yHE&8*jz77R)6&IHTGt$df9y|a52$9?XM?xs!?O4*VaBd`>VGai z4Ff!8Q55+&wk`WpHGIFn{lN*EdXNqkgd-ZO9Si+`3hMiz#)fHBE$&mnp6{zc{)ZT zc$S!^;QPySsW^k7_T1LX0Lz2$_lMutFsHm@ENDe=&;7#J2{iqPFOu?KM8CR1pM3}e z^ovqkt=dWR$;R}i6bSYL;Ix<3qF8qQK0>L}{uYYxBT9{>!A>{|{4$E2%)7G7-v>G+ z)}q+&w!v^AzKa5$=&jNV)Pj?)AS$*8-JA+|6d&DJ_q=@&&CrW-m*hl(iH~6!C)nz% z#Q0R5TOR$Ef0PdLzex-Ou7DD)OHX1>=-GTfl;MpLWWbJ0kq>cXvzo$|f1mZ7vRMR~ z{OO*)d)7E3uN0M}(oz@jNk2MCk0LqdA_C-1COS5b~FHpZ}E#AoXFU@|vm>xY=*nf%^A z!vHUh_E&M22>rWc4gVfjV2-|elR~(-0hH3sqKgEarGZP_3N}41$uF+TbnO7W&xHD0 z3yp>aFHe@V)WN=EhW}|hu0q}5nM}Wf-&oCm$JgD|sH%-E@)W{Ypd&8}6Datejq(St zW9NQ@4q7RuEs1sL$z?7v=F(>nWApQiDGz=WzH{uJg>}-0r`;+}W5HHA;IjJxLYVKa z_%2;wIf9xU5ju>fnU3;BTL=bzB)US8BOKiB!qI>g668I|J6e`OmAWl@17|;bg?mX- zXX~EVo^oNJ4BYjyv!7-gk(a-XJyW_fDvVPs^?b$>#u!_XPdnH215-F*dfW*V-|6UCG zX6gTxr6KC)wq3Ixa3jm{o-o$=NCz8NSHtA)u@&OM`3D^v$S+EkR5>B+)vvG4Uk&t+ z7iVBbTGues+rxv$+yjnfbB)~wm_EJ2#rdI)N&J0Q)8AB}FOIMS`(&+@xX}30ZJmT4 z<1>{#PNqWf&supW!j+PgvPu zEo(NE^a5rDZ?Ub2$R$ANGr{)%A_f>{xFDw7IKN;{7|hahoGX1PXPC3l9^iP5mHc}1 zbT4vB?JnCvL-6)KCdG~Zx4l*qPyhA$gsqZ9^G_9ZrV<_P#$Ijk(2;Ekw^qiUuvI9w zRWktr=eWfnNNxaA5Fn0`ZhoV$_#)f-se|!k*EK zc2ts(2>@Rd2KF0@RJ%kG>9?*yxC!wv9B}_K+Kp-4_KeX0DW#rQf Date: Thu, 8 Oct 2026 22:42:46 -0400 Subject: [PATCH 020/104] fix(mappers): a 24C02 keeps its word address across a START on mapper 16 too The Datach work (c87ea6db) found that the serial-EEPROM model cleared the 24C02's word address on every START. A 24C02 random read is a dummy write of the word address, a repeated START, then a read-mode device select, and the read begins at the address just written: the chip's address counter is not reset by a START. Clearing it made every random read start at $00. On mapper 157 that reformatted Ultraman Club's EEPROM at every boot, so the flag (Eeprom::keeps_word_address) went in, switched on for 157 only, and the Datach agent left mapper 16 as an open question on the belief that changing it would move shipped frames. It is a property of the chip, not the board, so it now defaults on for every 24C02 (Eeprom::new sets it to !is_x24c01) and the Datach-specific setter is gone. The X24C01 (mapper 159, and the Datach's external chip) keeps the old behaviour: it takes its word address after every START, so clearing it there is unobservable either way. Red first: m016_24c02_random_read_starts_at_the_written_address drives the LZ93D50 + 24C02 board through $800D with a byte write to $5A, then a dummy write of $5A, a repeated START and a read select. On the previous model it fails "random read of $5A: left 255, right 60" (it read the erased byte at $00); with the change it passes. The frame-change premise did not hold. external_coverage filtered to mapper-016, mapper-159 and mapper-157 passes unchanged, so no baseline of the six staged mapper-16 dumps (Akuma-kun, Crayon Shin-chan, Dragon Ball Daimaou Fukkatsu, Dragon Ball 3, DBZ Gaiden, DBZ II), the 159 dumps or the 157 dumps moves within their captures. It still alters bus-visible behaviour against v3.1.0 for any game that does a random read later on, so it is listed on v3.2.0's EMULATION_EPOCH 4 row with the mapper 290 fix; no further epoch bump is needed within the release. Verified: cargo fmt --check; rustynes-mappers clippy -D warnings; rustynes-mappers lib 976 passed; rustynes-core epoch tests 3 passed; external_extended m157 8 passed (the two barcode-scan tests read the same cards as before). docs/mappers.md's mapper 157 paragraph replaces the open question with what was done and measured. The EEPROM state machine is in m016's Mesen2-derived region; this edits a flag default inside it under the existing Provenance header and section 1 row, and the new logic was written from the 24C02 protocol. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/hardware_options.rs | 2 +- .../rustynes-mappers/src/m016_bandai_fcg.rs | 57 ++++++++++++++----- docs/mappers.md | 13 +++-- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index e78add46..addec5d8 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -96,7 +96,7 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize; /// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) | /// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) | /// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) | -/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots) | +/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots); a 24C02 keeps its word address across a START (mapper 16, 157) | pub const EMULATION_EPOCH: u32 = 4; /// Every host-settable option that changes what the emulated console does. diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs index 14541eae..9134d887 100644 --- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs +++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs @@ -44,7 +44,7 @@ //! same read is the scanner ([`BarcodeReader`], `datach_barcode.rs`). The variant's code //! lives outside the derived EEPROM region below, except for the //! `keeps_word_address` flag on [`Eeprom`], written from the 24C02's -//! random-read protocol (see the field) and switched on only here. +//! random-read protocol (see the field) and on for every 24C02. //! //! ## Offsets (relative to the window base, masked to `$x..F`) //! @@ -203,18 +203,19 @@ struct Eeprom { counter: u8, /// The bit currently driven back onto SDA toward the CPU (high = 1). output: u8, - /// v3.2.0, mapper 157 only: a START keeps the word address instead of + /// v3.2.0, every 24C02: a START keeps the word address instead of /// clearing it. A 24C02 "random read" is a dummy write of the word address /// followed by a repeated START and a read-mode device select, and the /// read then begins at that address: the chip's address counter is not /// reset by a START. Clearing it made every random read start at `$00`. - /// *Ultraman Club - Supokon Fight!* reads its file table that way, and - /// with the address cleared it reformatted the main unit's EEPROM and - /// stopped on "no free block" at every boot; keeping it, the game reaches - /// its title (measured 2026-10-08). Written from that protocol - /// description, not from any emulator. Off for mappers 16 / 159, whose - /// shipped behaviour this change does not touch (see `docs/mappers.md`, - /// mapper 157, for the open question it raises about mapper 16). + /// Found on mapper 157: *Ultraman Club - Supokon Fight!* reads its file + /// table that way, and with the address cleared it reformatted the main + /// unit's EEPROM and stopped on "no free block" at every boot; keeping it, + /// the game reaches its title (measured 2026-10-08). Then applied to + /// mapper 16's 24C02 as well, since it is the chip's behaviour, not the + /// board's. Written from that protocol description, not from any emulator. + /// Off for the X24C01, which takes its word address after every START, so + /// the clearing is unobservable there. keeps_word_address: bool, } @@ -243,7 +244,7 @@ impl Eeprom { data: 0, counter: 0, output: 1, - keeps_word_address: false, + keeps_word_address: !is_x24c01, } } @@ -576,9 +577,6 @@ impl BandaiFcg { external_x24c01: bool, ) -> Result { let mut m = Self::new(prg_rom, chr_rom, mirroring, FcgVariant::Datach)?; - if let Some(ee) = m.eeprom.as_mut() { - ee.keeps_word_address = true; - } if external_x24c01 { m.ext_eeprom = Some(Eeprom::new(128, true)); } @@ -1703,6 +1701,39 @@ mod tests { assert_eq!(m.sram()[0x00], 0xFF, "byte $00 untouched"); } + /// v3.2.0: the same random read on mapper 16's LZ93D50 + 24C02 board. + /// Keeping the word address across a START is a property of the 24C02, + /// not of the Datach, so it holds on every board that carries one. + #[test] + fn m016_24c02_random_read_starts_at_the_written_address() { + let mut m = BandaiFcg::new( + synth_prg(8), + synth_chr(16), + Mirroring::Vertical, + FcgVariant::Lz93d50_24c02, + ) + .unwrap(); + { + let mut d = DatachI2c { + m: &mut m, + external: false, + }; + d.start(); + assert!(d.send_msb_first(0xA0), "device select acked"); + assert!(d.send_msb_first(0x5A), "word address acked"); + assert!(d.send_msb_first(0x3C), "data acked"); + d.stop(); + d.start(); + assert!(d.send_msb_first(0xA0)); + assert!(d.send_msb_first(0x5A)); + d.start(); // repeated START + assert!(d.send_msb_first(0xA1), "read select acked"); + assert_eq!(d.recv_msb_first(), 0x3C, "random read of $5A"); + d.stop(); + } + assert_eq!(m.sram()[0x5A], 0x3C); + } + /// The cartridge's X24C01 answers only on its own clock, `$8000-$8003` /// bit 3, and (Bandai's convention, modelled by the existing X24C01 /// state machine) shifts LSB-first. The 24C02 never sees that clock, so diff --git a/docs/mappers.md b/docs/mappers.md index 328c2e0d..9d8c9f90 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -868,11 +868,14 @@ reformatted the EEPROM at every boot and stopped on "no free block"; *Dragon Ball Z* and *SD Gundam Wars* listed garbage save files. Mapper 157's 24C02 keeps the address across a START, written from that protocol description; with it all three reach their titles and *SD Gundam Wars* lists four empty -files. The flag is off for mappers 16 and 159, whose behaviour this release -does not change. **Open question:** the mapper-16 LZ93D50 games use the same -24C02 and may do random reads too; changing it there alters shipped frames, -so it needs its own change with an `EMULATION_EPOCH` bump and re-blessed -baselines. +files. Keeping the address is a property of the 24C02, so v3.2.0 applies it to +every board that carries one: mapper 16's LZ93D50 + 24C02 too +(`m016_24c02_random_read_starts_at_the_written_address`, which read `$FF` +from the cleared address before). No `external_coverage` baseline of the six +staged mapper-16 dumps, nor of 157 or 159, moved; it falls under v3.2.0's +`EMULATION_EPOCH` 4 all the same. The X24C01 (mapper 159, and the Datach's +external chip) is unchanged: it takes its word address after every START, so +clearing it there is unobservable. **Power-on EEPROM contents.** `$FF` throughout. Every game detects that as unformatted and formats it on first boot (*Ultraman Club* writes `BA` then From 7a437805df39646b986d99f806faad84f32d936d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 22:46:13 -0400 Subject: [PATCH 021/104] fix(mappers): read the VRC4 IRQ mode bit as the wiki gives it m021_vrc4.rs (mappers 21, 23, 25) took bit 2 of the IRQ control register as "1 = scanline mode". nesdev_wiki/VRC_IRQ.xhtml gives the opposite: "IRQ Mode (1 = cycle mode, 0 = scanline mode)", with a prescaler dividing the CPU clock by 114, 114, 113 in scanline mode and no prescaler in cycle mode. VRC6 (m024) and VRC7 (m085) already read it that way; only the VRC4 was inverted. A game that asked for a raster split therefore got a counter clocked every CPU cycle, about 114 times too fast, and one that asked for cycle mode got a scanline counter. Found while adding mapper 252 (a Waixing VRC4 clone): San Guo Zhi writes latch $52 with control $02 at vblank, which is 174 scanlines to its text box, and instead took the IRQ two lines into the frame, so the box drew with the map's CHR banks. Its CHR-RAM held clean glyphs throughout; only the split was wrong. Fix: irq_mode_scanline = (value & 0x04) == 0. The existing cycle-mode unit test wrote $02 believing bit 2 clear meant cycle mode; it now writes $06. Verification - New test vrc4_irq_mode_bit_2_clear_is_scanline_mode: M = 0 does not fire within 10 CPU cycles but does after two prescaled clocks; M = 1 fires after two CPU cycles. Red on the old line (the existing cycle-mode test fails too), green with the fix. rustynes-mappers 976 lib tests, rustynes-core 253 lib tests, m22, holy_mapperel and external_extended (141) pass. - Nine of the 17 staged mapper 21/23/25 dumps render differently, and every difference inspected is a repair: Bio Miracle Bokutte Upa gains its status bar, both Wai Wai World 2 dumps draw the map logo, Gradius II reaches "PLAYER 1 START" in the capture window, Crisis Force loses a black band at the top of the playfield. Re-blessed: nine external_coverage snapshots and the external_real_games Crisis Force and Akumajou Special snapshots. Epoch: this moves commercial-ROM output against v3.1.0, so ADR 0045 requires EMULATION_EPOCH to rise for v3.2.0. The epoch_fingerprint panel has no VRC4 ROM and still passes; the rise is left to the release integrator with the CHANGELOG, as docs/agents/ci-and-release.md places it at the cut. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/m021_vrc4.rs | 41 +++++++++++++++++-- ...on_Gaiden_2_Tenka_no_Zaihou_Japan_zip.snap | 2 +- ...Parsley_Jou_Japan_En_1_01_2018_update.snap | 6 +-- ..._Wai_World_2_SOS_Paseri_Jou_Japan_zip.snap | 6 +-- ...4a_c_Wai_Wai_World_2_SOS_Paseri_Jou_J.snap | 6 +-- ...3_VRC2_VRC4_Crisis_Force_Japan_En_1_0.snap | 8 ++-- ...RC4_Bio_Miracle_Bokutte_Upa_Japan_zip.snap | 6 +-- ...er_025_VRC2_VRC4_Gradius_II_Japan_zip.snap | 8 ++-- ...25_VRC2_VRC4_La_Bi_Xiao_Xin_II_Ch_zip.snap | 6 +-- ..._Racer_Mini_Yonku_Japan_Cup_Japan_zip.snap | 4 +- ...games__external_vrc4_akumajou_special.snap | 2 +- ...eal_games__external_vrc4_crisis_force.snap | 2 +- docs/mappers.md | 14 +++++++ 13 files changed, 79 insertions(+), 32 deletions(-) diff --git a/crates/rustynes-mappers/src/m021_vrc4.rs b/crates/rustynes-mappers/src/m021_vrc4.rs index 4f4ad30f..0553f120 100644 --- a/crates/rustynes-mappers/src/m021_vrc4.rs +++ b/crates/rustynes-mappers/src/m021_vrc4.rs @@ -306,10 +306,13 @@ impl Mapper for Vrc4 { } (false, true) => { // Control: bit 0 = enable_after_ack, bit 1 = enable now, - // bit 2 = mode (1 = scanline mode). + // bit 2 = mode M. `nesdev_wiki/VRC_IRQ.xhtml`: "IRQ Mode + // (1 = cycle mode, 0 = scanline mode)". Until v3.2.0 this + // read the bit the other way round (VRC6 / VRC7 had it + // right), so a scanline-mode split ran every CPU cycle. self.irq_enable_after_ack = (value & 0x01) != 0; self.irq_enabled = (value & 0x02) != 0; - self.irq_mode_scanline = (value & 0x04) != 0; + self.irq_mode_scanline = (value & 0x04) == 0; self.irq_pending = false; if self.irq_enabled { self.irq_counter = self.irq_latch; @@ -529,8 +532,8 @@ mod tests { // VRC4a: a0_bit=1, a1_bit=2. Control is at $F004 (a0=0, a1=1). // Set latch low byte = 0xE. m.cpu_write(0xF000, 0xE); - // Enable: bit 1 (enable now), mode=cycle (bit 2 = 0). - m.cpu_write(0xF004, 0x02); + // Enable: bit 1 (enable now), mode=cycle (bit 2 = 1, VRC_IRQ). + m.cpu_write(0xF004, 0x06); // From counter=latch=0xE, ticks until 0xFF: 0xFF-0xE = 0xF1 ticks // for the wrap, plus one to set pending. for _ in 0..0xF1 + 1 { @@ -539,6 +542,36 @@ mod tests { assert!(m.irq_pending()); } + /// `nesdev_wiki/VRC_IRQ.xhtml`: control bit 2 is M, "IRQ Mode (1 = cycle + /// mode, 0 = scanline mode)", and in scanline mode a prescaler divides the + /// CPU clock by 113 2/3 before the counter sees it. Until v3.2.0 the bit + /// was read inverted, so a game's scanline split fired every CPU cycle + /// (Bio Miracle Bokutte Upa lost its status bar; mapper 252's San Guo Zhi + /// drew its text box from the wrong CHR banks). + #[test] + fn vrc4_irq_mode_bit_2_clear_is_scanline_mode() { + let mut m = Vrc4::new(synth(8), synth_chr(8), 21, 1, Mirroring::Vertical).unwrap(); + m.cpu_write(0xF000, 0x0E); // latch $FE: two counter clocks to the IRQ + m.cpu_write(0xF002, 0x0F); + m.cpu_write(0xF004, 0x02); // enable, M = 0: scanline mode + for _ in 0..10 { + m.notify_cpu_cycle(); + } + assert!( + !m.irq_pending(), + "M = 0 is scanline mode: 10 CPU cycles is not two scanlines" + ); + for _ in 0..(2 * 114) { + m.notify_cpu_cycle(); + } + assert!(m.irq_pending(), "two prescaled clocks (~227 CPU cycles)"); + // M = 1 is cycle mode: the counter sees every CPU cycle. + m.cpu_write(0xF004, 0x06); + m.notify_cpu_cycle(); + m.notify_cpu_cycle(); + assert!(m.irq_pending(), "M = 1: two CPU cycles"); + } + /// Core audit v2.9.2 AUD-02: the section carries the 8 KiB PRG-RAM and, /// on a CHR-RAM board, the 8 KiB CHR-RAM. The core-level pin is /// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`. diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Ganbare_Goemon_Gaiden_2_Tenka_no_Zaihou_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Ganbare_Goemon_Gaiden_2_Tenka_no_Zaihou_Japan_zip.snap index 961cdc70..09035fbc 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Ganbare_Goemon_Gaiden_2_Tenka_no_Zaihou_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Ganbare_Goemon_Gaiden_2_Tenka_no_Zaihou_Japan_zip.snap @@ -8,6 +8,6 @@ frames=1141 fb_bytes=245760 cycles=33949774 audio_samples=836505 -audio_fnv1a64=dd77eb3d9768c666 +audio_fnv1a64=eaabcbc2936f11d6 checkpoint f900 fb_fnv1a64=25e10b8e07698d95 checkpoint f1100 fb_fnv1a64=25e10b8e07698d95 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Parsley_Jou_Japan_En_1_01_2018_update.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Parsley_Jou_Japan_En_1_01_2018_update.snap index 34850d91..d0ad6394 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Parsley_Jou_Japan_En_1_01_2018_update.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Parsley_Jou_Japan_En_1_01_2018_update.snap @@ -6,8 +6,8 @@ rom=mapper-021-VRC2-VRC4/Wai Wai World 2 - SOS!! Parsley Jou (Japan) (En) (1.01) rom_sha256=93346118d36c6ea048e9a649d02595549620ee7cdaf8e92caaefcc7700b3336d frames=1141 fb_bytes=245760 -cycles=33949780 +cycles=33949779 audio_samples=836505 -audio_fnv1a64=c632ff535966bfe6 +audio_fnv1a64=62629110216be9d5 checkpoint f900 fb_fnv1a64=c83606cc6b557729 -checkpoint f1100 fb_fnv1a64=b6427844fd7fa7b3 +checkpoint f1100 fb_fnv1a64=2be4aa7444349863 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Paseri_Jou_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Paseri_Jou_Japan_zip.snap index 258d8590..9ad047d2 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Paseri_Jou_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC2_VRC4_Wai_Wai_World_2_SOS_Paseri_Jou_Japan_zip.snap @@ -6,8 +6,8 @@ rom=mapper-021-VRC2-VRC4/Wai Wai World 2 - SOS!! Paseri Jou (Japan).zip rom_sha256=43cba4b0749894735289e95c267b2e232f296962de5fc2b8c1f2e7d06ec474a0 frames=1141 fb_bytes=245760 -cycles=33949780 +cycles=33949779 audio_samples=836505 -audio_fnv1a64=403627c95e5119a7 +audio_fnv1a64=2b6463dfa1040292 checkpoint f900 fb_fnv1a64=47de9c9ce1554cce -checkpoint f1100 fb_fnv1a64=b6427844fd7fa7b3 +checkpoint f1100 fb_fnv1a64=3230c495fdf788df diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC4a_c_Wai_Wai_World_2_SOS_Paseri_Jou_J.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC4a_c_Wai_Wai_World_2_SOS_Paseri_Jou_J.snap index 8944f324..9c0a0f75 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC4a_c_Wai_Wai_World_2_SOS_Paseri_Jou_J.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_021_VRC4a_c_Wai_Wai_World_2_SOS_Paseri_Jou_J.snap @@ -6,8 +6,8 @@ rom=mapper-021-VRC4a-c/Wai Wai World 2 - SOS!! Paseri Jou (J) [!].nes rom_sha256=575b0346e5e2ed7186d95c2b5f67fe7b51f18514edc0cf84b745b31688443587 frames=1141 fb_bytes=245760 -cycles=33949780 +cycles=33949779 audio_samples=836505 -audio_fnv1a64=403627c95e5119a7 +audio_fnv1a64=2b6463dfa1040292 checkpoint f900 fb_fnv1a64=47de9c9ce1554cce -checkpoint f1100 fb_fnv1a64=b6427844fd7fa7b3 +checkpoint f1100 fb_fnv1a64=3230c495fdf788df diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_023_VRC2_VRC4_Crisis_Force_Japan_En_1_0.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_023_VRC2_VRC4_Crisis_Force_Japan_En_1_0.snap index 49a17ed6..ade5575d 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_023_VRC2_VRC4_Crisis_Force_Japan_En_1_0.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_023_VRC2_VRC4_Crisis_Force_Japan_En_1_0.snap @@ -6,8 +6,8 @@ rom=mapper-023-VRC2-VRC4/Crisis Force (Japan) (En) (1.0).nes rom_sha256=75541672847f565a06a4ea8bf9e658141ed65e4445a2832a74fabd44951032c8 frames=1141 fb_bytes=245760 -cycles=33949780 +cycles=33949779 audio_samples=836505 -audio_fnv1a64=2dad0a0b40dfe250 -checkpoint f900 fb_fnv1a64=e9b9aab46c67f1a3 -checkpoint f1100 fb_fnv1a64=4b91989766c9853b +audio_fnv1a64=9c4678ad2c76d723 +checkpoint f900 fb_fnv1a64=a91e27d7ec6e59d9 +checkpoint f1100 fb_fnv1a64=da076083e81b1686 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Bio_Miracle_Bokutte_Upa_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Bio_Miracle_Bokutte_Upa_Japan_zip.snap index 81729b05..ae432db2 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Bio_Miracle_Bokutte_Upa_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Bio_Miracle_Bokutte_Upa_Japan_zip.snap @@ -8,6 +8,6 @@ frames=1141 fb_bytes=245760 cycles=33949778 audio_samples=836505 -audio_fnv1a64=22d94ff9f4a3af22 -checkpoint f900 fb_fnv1a64=0fec2ac925a9871c -checkpoint f1100 fb_fnv1a64=0fec2ac925a9871c +audio_fnv1a64=c6c7c2dcb97ad720 +checkpoint f900 fb_fnv1a64=65ac182eebfa2953 +checkpoint f1100 fb_fnv1a64=3d32424342908a56 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Gradius_II_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Gradius_II_Japan_zip.snap index 49a6ed47..5dac8d20 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Gradius_II_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Gradius_II_Japan_zip.snap @@ -6,8 +6,8 @@ rom=mapper-025-VRC2-VRC4/Gradius II (Japan).zip rom_sha256=8fd556a7be216178d481a983243a3f3e96bd9a9ff205377c1a1ea98144cfa2f2 frames=1141 fb_bytes=245760 -cycles=33949780 +cycles=33949778 audio_samples=836505 -audio_fnv1a64=a8c4ba7ea5083da1 -checkpoint f900 fb_fnv1a64=4cafe0ccf3f43684 -checkpoint f1100 fb_fnv1a64=b5bc7cd133eee6c1 +audio_fnv1a64=991e8b1c1ec49a4f +checkpoint f900 fb_fnv1a64=581c22c27a91b2f5 +checkpoint f1100 fb_fnv1a64=871eb6ba0826b9b8 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_La_Bi_Xiao_Xin_II_Ch_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_La_Bi_Xiao_Xin_II_Ch_zip.snap index 982093df..54bcb6d3 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_La_Bi_Xiao_Xin_II_Ch_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_La_Bi_Xiao_Xin_II_Ch_zip.snap @@ -8,6 +8,6 @@ frames=1141 fb_bytes=245760 cycles=33949779 audio_samples=836505 -audio_fnv1a64=cb95cd2e4c20e879 -checkpoint f900 fb_fnv1a64=9a4a23e62522cea2 -checkpoint f1100 fb_fnv1a64=9a4a23e62522cea2 +audio_fnv1a64=9f5bbb53f1752030 +checkpoint f900 fb_fnv1a64=2d230b8d5e2947dd +checkpoint f1100 fb_fnv1a64=3c3b37471e55584c diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Racer_Mini_Yonku_Japan_Cup_Japan_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Racer_Mini_Yonku_Japan_Cup_Japan_zip.snap index d7a232a1..fbb9d89f 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Racer_Mini_Yonku_Japan_Cup_Japan_zip.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_025_VRC2_VRC4_Racer_Mini_Yonku_Japan_Cup_Japan_zip.snap @@ -6,8 +6,8 @@ rom=mapper-025-VRC2-VRC4/Racer Mini Yonku - Japan Cup (Japan).zip rom_sha256=661ea626c01b902a1acd3af415c7b9ffd2f63cb91fcc249fafa6c3d06aad4a22 frames=1141 fb_bytes=245760 -cycles=33949774 +cycles=33949772 audio_samples=836505 -audio_fnv1a64=8802016a72052876 +audio_fnv1a64=ed594ffb31fe31bf checkpoint f900 fb_fnv1a64=32bc27470aa54df4 checkpoint f1100 fb_fnv1a64=eb64ad99dd45ebb4 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_akumajou_special.snap b/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_akumajou_special.snap index 83456736..f9e27a01 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_akumajou_special.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_akumajou_special.snap @@ -6,7 +6,7 @@ rom=mapper-023-VRC2-VRC4/Akumajou Special - Boku Dracula-kun (Japan) (En) (1.0). rom_sha256=e10ce161608c4765df9e58e0942ba1fd37c36336878e78e22ddc8b967ece5f73 frames=600 fb_bytes=245760 -cycles=17838521 +cycles=17838522 audio_samples=439524 audio_fnv1a64=29428bd1a30f79ce checkpoint f600 fb_fnv1a64=61f3640469dfb583 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_crisis_force.snap b/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_crisis_force.snap index 8cf12a71..25c1cf8a 100644 --- a/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_crisis_force.snap +++ b/crates/rustynes-test-harness/tests/snapshots/external_real_games__external_vrc4_crisis_force.snap @@ -6,7 +6,7 @@ rom=mapper-023-VRC2-VRC4/Crisis Force (Japan) (En) (1.0).nes rom_sha256=75541672847f565a06a4ea8bf9e658141ed65e4445a2832a74fabd44951032c8 frames=600 fb_bytes=245760 -cycles=17838522 +cycles=17838523 audio_samples=439524 audio_fnv1a64=dda9e74ae2696052 checkpoint f600 fb_fnv1a64=67ac379c102911a5 diff --git a/docs/mappers.md b/docs/mappers.md index cad04660..1a06a7ab 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,20 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | | 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`HeldStart`: the menu ignores one-frame taps). | +**VRC4 IRQ mode bit, corrected (v3.2.0).** `m021_vrc4.rs` (mappers 21, 23, +25) read bit 2 of the IRQ control register the wrong way round: `VRC_IRQ` +gives "IRQ Mode (1 = cycle mode, 0 = scanline mode)", and VRC6 and VRC7 had it +right. A game that asked for a scanline split got an IRQ that counted CPU +cycles, roughly 114 times too fast. Found through mapper 252, whose *San Guo +Zhi* sets latch `$52` in scanline mode for its text box and drew the box from +the wrong CHR banks. With the bit corrected, nine staged VRC4 dumps render +differently: *Bio Miracle Bokutte Upa* gains its status bar, the *Wai Wai World +2* map its logo, *Gradius II* reaches "PLAYER 1 START", and *Crisis Force* +loses a black band at the top of the playfield. The `external_coverage` and +`external_real_games` snapshots for them were re-blessed in the same change; +this moves commercial-ROM output, so the release must raise +`EMULATION_EPOCH` (ADR 0045). + ### Mapper accuracy tiering (v1.2.0) Every supported family is classified `Core` / `Curated` / `BestEffort` by From 3a4aba528014b755bcb104f5e7e0eb35036b479d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 23:20:48 -0400 Subject: [PATCH 022/104] docs(changelog): v3.2.0's Unreleased entries so far Written from the branch log (main..feat/v3.2.0, first parent) rather than from memory, so the release notes at the cut start from a list that already matches the commits: the epoch 4 break (mapper 290 and the 24C02 word address), mapper 157 with the barcode reader, the six MMC3-derived families, the Famicom-wired coverage boots, the D23 promotions and the tier counts they leave (51 / 121 / 26), and the two fixes. Not yet listed, because they are not merged: the FFE / TXC / Taito / Tengen families on the misc branch (whose VRC4 IRQ-mode change will need its own entry and an epoch check), and every MiSTer-core change. The release-state audits all pass on this tree (release_anchor 16, release_state_prose 9, release_notes_render 4, provenance_record 2, libretro_info 6, contribution_checklist 14, cosim_manifest 5); markdownlint passes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e49f10..e09eb8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,48 @@ cycle-accurate core later replaced. ## [Unreleased] +### Breaking + +- **Movies and netplay from v3.1.0 are refused.** `EMULATION_EPOCH` rises + from 3 to 4: the mapper 290 and 24C02 fixes below change what the bus sees. + A v3.1.x peer or movie is refused as another emulator version, naming its + epoch. Save states are not affected by the epoch. + +### Added + +- **Mapper 157, the Bandai Datach Joint ROM System, with its barcode + reader.** Six games boot. *Dragon Ball Z* and *SD Gundam Wars* read a + scanned card, entered as a 13- or 8-digit JAN/EAN code from Emulation > + Scan Barcode... (refused during movie recording or playback and netplay). + The reader's polarity, idle level and speed are not documented anywhere; + they were measured against the games. +- **Six MMC3-derived families**: 114 and 182 (SuperGame / Hosenkan), 215 + (Sugar Softec), 197, 165 (the Chinese *Fire Emblem* ports) and 116 + (SOMARI-P, which switches between VRC2, MMC3 and MMC1). All Curated, each + booted from real dumps. +- **The coverage sweep boots Famicom-only carts as a Famicom.** Ten dumps + listed as blank since v2.9.6 write the PPU during the front-loading NES's + power-on warm-up, which ignores those writes; on the Famicom model they + draw. The frontend still does not pick the model from the ROM: choose + Famicom in Settings for *Sansuu 1-3*, *Final Combat*, *Digital Dragon* and + the like. + +### Changed + +- **Mappers 47, 121, 154, 191 and 243 are Curated**, on local-dump evidence + recorded in `docs/compatibility.md` (maintainer decision D23). The tiers + are now 51 Core, 121 Curated and 26 BestEffort. + +### Fixed + +- **Mapper 290 (*Asder 20-in-1*) boots.** Its 32 KiB PRG mode read the + upper half through the 16 KiB window, so the reset vector came from the + wrong bank. +- **A 24C02 serial EEPROM keeps its word address across a START** (mappers + 16 and 157), so a random read returns the byte asked for instead of byte + `$00`. On the Datach this was the difference between booting and + reformatting the save at every power-on. + ## [3.1.0] - 2026-10-08 - "Bellwether" (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it) The first release of the v3.1 to v4.0 line. AccuracyCoin is re-synced to its From e12b32db9f9a040a80f4ac09f4c6d9e809c35a03 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 23:22:07 -0400 Subject: [PATCH 023/104] docs(plan): record item 5's region placeholder, the VRC4 IRQ finding, 24C02 Three findings into the v3.2.0 plan's "Findings during the build": - Item 5 asked for "the region placeholder off". The MiSTer core's OSD has no region item at all on feat/v3.2.0 (palette, custom .pal, and the sprite limit this release adds), and emu.sv states PAL timing is not implemented, so there is nothing to switch off; a region entry arrives with PAL in v3.6.0. Recorded so the row is closed by evidence rather than by silence. - The misc branch found VRC4's IRQ mode bit inverted for mappers 21, 23 and 25. Re-checked here against nesdev_wiki/VRC_IRQ.xhtml ("IRQ Mode (1 = cycle mode, 0 = scanline mode)") before merging. It moves commercial frames; epoch 4 already covers v3.2.0. - The 24C02 word-address fix applied to mapper 16 (ba20c328). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/plans/v3.2.0-plan.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index f33407aa..290178d3 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -88,6 +88,20 @@ until the hardware release shipped.) The codename is asked at the cut. correctly, then crashes into data at `$FF40` in frame 4; no 176 submapper or other Waixing mapper boots it, so a bad dump is the leading explanation. Both stay in KNOWN_BLANK with the cause at the entry. Item 2 is done. +- **Item 5's "region placeholder off" needs no change.** The core's OSD has + no region item (`rtl/emu.sv` on `feat/v3.2.0`: the options are the + palette, the custom `.pal` load and, from this release, the sprite limit), + and PAL timing is not implemented (`emu.sv`'s own comment at the video + mode). A region entry appears when PAL does (v3.6.0, F4), not before. +- **VRC4's IRQ mode bit was inverted (misc branch, `7a437805`).** Mappers + 21, 23 and 25 read `$x00x` control bit 2 as "1 = scanline"; VRC_IRQ says + "1 = cycle mode, 0 = scanline mode", as VRC6 and VRC7 already had it. + Checked against the vendored page. It moves commercial frames (nine of 17 + staged VRC2/4 dumps, every difference inspected a repair), which v3.2.0's + epoch 4 covers. +- **The 24C02 fix generalised.** The Datach found the EEPROM model cleared + the 24C02's word address on START; it is the chip's behaviour, so mapper + 16 has it too (`ba20c328`). No mapper-16 baseline moved. ## Sprints From ac10b03f26c62f42185b9262ee9d1708ed18e20a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 23:28:04 -0400 Subject: [PATCH 024/104] feat(cosim): export goldens with a Four Score, a Vaus or Game Genie codes Item 8 of v3.2.0 gives the RustyNES_MiSTer core three input features -- the Four Score (FB-8), the Arkanoid "Vaus" paddle (FB-10) and Game Genie style cheats (FB-6) -- and each needs a co-simulation gate. A gate needs a golden exported with the same device attached on the oracle side, and `nes_golden_export` had no way to ask for one: the emulator already models all three (`Nes::set_four_score`, `Nes::set_paddle`, `Nes::add_genie_code`), the exporter simply never called them. Three flags, each attaching its device for the WHOLE run: --four-score P1:P2:P3:P4 adapter on, four pads' button bytes (2 hex digits each, `Buttons` bit order) --vaus POS:FIRE NES 7-pin Arkanoid controller on $4017: knob position (2 hex digits) and fire (0/1) --genie CODE a 6/8-letter code, repeatable, order kept Held constant rather than scheduled per frame like `--press-start`: a frame window would have to be reproduced in CPU cycles by the DUT's testbench, while a constant input needs nothing reproduced, and a stimulus ROM that reads the device every frame exercises every bit of it anyway. Provenance: each flag writes a canonical manifest line (`four_score`, `vaus`, `genie`) and ONLY when given, so all 132 committed manifests -- and every artifact -- are byte-identical to the exporter before this change. The sibling's recipe generator turns those lines straight back into the flags. Refused at the boundary, because each would export a plausible golden of something other than what was asked: `--four-score` with `--press-start` (both write port 1's buttons, so the press would overwrite pad 1 for its window); `--four-score` with `--vaus` (NESdev "Arkanoid controller": "No version is compatible with four player adapters"); an undecodable code; and two codes on one address (the bus keeps one code per address, so the manifest would list a code that never applied). Malformed specs exit with the usage text. EMULATION_EPOCH does not move: no core code changes, and with no flag given the export is unchanged. The new flags only call public `Nes` API that the frontends already use. Tests (`device_tests`, through the real parsers, validator and attach path): the two spec parsers' accepted and refused forms; all four refusals plus a coherent combination; the manifest block (empty with no device, canonical lines otherwise); and an end-to-end run of a ROM that strobes the pads and stores the first $4016/$4017 reads, so a flag that parsed and attached nothing fails. Measured values: control $40/$40; Four Score with pads 1 and 2 holding A gives $41/$41; Vaus $A7 + fire gives $40/$48 (MSb set goes out inverted as D4=0, fire on D3); Vaus $27 without fire gives $50 on $4017; two codes give genie_codes().count() == 2. Mutation: attaching the paddle to port 0 instead of 1 FAILS `the_devices_actually_reach_the_emulated_ports`: assertion `left == right` failed left: (72, 64) right: (64, 72) Restored from a snapshot; 13/13 pass again. Checked: cargo fmt --check, cargo clippy --all-targets -D warnings (with and without ppu-state-trace), cargo test (59 passed, 6 suites), cargo doc -D warnings, all on the excluded rustynes-cosim manifest. docs/mister.md gains the flag table and the refusal rules. Not established here: the goldens these flags produce, and the gates that compare against them, live in the sibling repository; the stems they export are reproducible from this branch until the sibling's oracle pin moves past it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../src/bin/nes_golden_export.rs | 422 +++++++++++++++++- docs/mister.md | 29 ++ 2 files changed, 450 insertions(+), 1 deletion(-) diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs index 33f0dc87..00f834e3 100644 --- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs +++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs @@ -6,8 +6,15 @@ //! nes_golden_export --rom --out

[--seed N] [--frames N] //! [--boot-trace START..END] [--irq-trace CAP] //! [--checkpoint-interval N] +//! [--four-score P1:P2:P3:P4] [--vaus POS:FIRE] [--genie CODE]... //! ``` //! +//! The last three (v3.2.0) attach STIMULUS devices for the whole run -- a Four +//! Score with four pads, an Arkanoid paddle on `$4017`, Game Genie codes -- so +//! the `RustyNES_MiSTer` input and cheat gates have a golden to compare against. +//! Each is recorded in the manifest (`four_score`, `vaus`, `genie`) only when +//! given; see `DeviceArgs`. +//! //! Writes, under ``: //! //! | file | format | consumed by | @@ -93,6 +100,195 @@ struct Args { inject_nmi_at: Option, inject_irq_at: Option, inject_hold: u64, + /// v3.2.0 (item 8) -- the input devices and cheat codes attached for the + /// whole run. See [`DeviceArgs`]. + devices: DeviceArgs, +} + +/// v3.2.0 (item 8): the STIMULUS the `RustyNES_MiSTer` input and cheat gates +/// need on the oracle side -- a Four Score with four pads, an Arkanoid (Vaus) +/// paddle on port 2, and Game Genie codes. +/// +/// Every one of these is a console INPUT the emulator already models (`Nes::set_four_score`, +/// `Nes::set_paddle`, `Nes::add_genie_code`); the exporter only had no way to +/// ask for them, so a DUT implementing the same device had nothing to be +/// compared against. They change no emulated behaviour of their own: with none +/// given, every artifact and every manifest line is byte-identical to the +/// exporter before this field existed (the manifest gains lines ONLY when one +/// is set, so the 132 committed manifests stay exactly what they are). +/// +/// Held CONSTANT for the run, deliberately. A frame-windowed schedule like +/// `--press-start` would need the DUT's testbench to reproduce the frame +/// boundaries in CPU cycles; a constant input needs nothing reproduced, and a +/// stimulus ROM that reads the device every frame exercises every bit of it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct DeviceArgs { + /// `--four-score P1:P2:P3:P4` -- the adapter on, and the four pads' button + /// bytes (bit order as `Buttons`: A, B, Select, Start, Up, Down, Left, Right + /// from bit 0). + four_score: Option<[u8; 4]>, + /// `--vaus POS:FIRE` -- an Arkanoid controller on port 2 (`$4017`), its + /// 8-bit knob position and whether the fire button is held. + vaus: Option<(u8, bool)>, + /// `--genie CODE`, repeatable -- 6- or 8-letter Game Genie codes, kept in + /// the order given (the manifest records them in that order). + genie: Vec, +} + +/// Parse `--four-score P1:P2:P3:P4`: four two-digit hex bytes. +/// +/// Exactly two digits each, so `1:2:3:4` is refused rather than read as +/// `01:02:03:04` -- the manifest records the canonical form, and a recipe +/// regenerated from it must reproduce the same argument the export saw. +fn parse_four_score_spec(spec: &str) -> Option<[u8; 4]> { + let parts: Vec<&str> = spec.split(':').collect(); + if parts.len() != 4 { + return None; + } + let mut out = [0u8; 4]; + for (slot, p) in out.iter_mut().zip(&parts) { + if p.len() != 2 { + return None; + } + *slot = u8::from_str_radix(p, 16).ok()?; + } + Some(out) +} + +/// Parse `--vaus POS:FIRE`: a two-digit hex knob position and `0` or `1`. +fn parse_vaus_spec(spec: &str) -> Option<(u8, bool)> { + let (pos, fire) = spec.split_once(':')?; + if pos.len() != 2 { + return None; + } + let pos = u8::from_str_radix(pos, 16).ok()?; + let fire = match fire { + "0" => false, + "1" => true, + _ => return None, + }; + Some((pos, fire)) +} + +/// Refuse a device request that would silently produce a golden describing +/// something other than what was asked. `None` when the request is coherent. +/// +/// Each rule names a combination that would RUN and export a plausible golden: +/// +/// - **Four Score with `--press-start`.** Both write port 1's buttons; the +/// press would overwrite the Four Score's pad 1 for its window and restore +/// zero after it, so the manifest's `four_score` line would be false for part +/// of the run. +/// - **Four Score with `--vaus`.** The `NESdev` "Arkanoid controller" page: "No +/// version is compatible with four player adapters." The oracle would let the +/// paddle take over port 2 regardless, producing a stream no console can. +/// - **Two Game Genie codes at one address.** The oracle keeps one code per +/// address, so the second REPLACES the first; the manifest would list a code +/// that was never active. +/// - **An undecodable code** is refused here rather than at `add_genie_code`, +/// so the message names the flag. +fn device_error(args: &Args) -> Option { + let d = &args.devices; + if d.four_score.is_some() && args.press_start.is_some() { + return Some( + "--four-score and --press-start both drive port 1's buttons; the press \ + would overwrite pad 1 for part of the run" + .to_owned(), + ); + } + if d.four_score.is_some() && d.vaus.is_some() { + return Some( + "--four-score with --vaus: the Arkanoid controller is not compatible with \ + four player adapters (NESdev \"Arkanoid controller\")" + .to_owned(), + ); + } + let mut seen: Vec = Vec::with_capacity(d.genie.len()); + for code in &d.genie { + let gc = match rustynes_core::GenieCode::new(code) { + Ok(gc) => gc, + Err(e) => return Some(format!("--genie {code:?}: {e}")), + }; + if seen.contains(&gc.addr()) { + return Some(format!( + "--genie {code:?} patches ${:04X}, which an earlier --genie already \ + patches; the oracle keeps one code per address, so the earlier one \ + would silently stop applying", + gc.addr() + )); + } + seen.push(gc.addr()); + } + None +} + +/// Attach the requested devices and codes to a freshly constructed oracle. +/// +/// Called before a single cycle runs, so the first strobe of the run already +/// sees them. Infallible once [`device_error`] has passed; the `Result` is the +/// genie decoder's, kept rather than unwrapped so a future path that skips the +/// validation fails with a message instead of a panic. +fn apply_devices(o: &mut Oracle, d: &DeviceArgs) -> Result<(), String> { + if let Some(pads) = d.four_score { + o.nes_mut().set_four_score(true); + for (port, bits) in pads.iter().enumerate() { + o.set_buttons(port, *bits); + } + } + if let Some((pos, fire)) = d.vaus { + // Port index 1 is `$4017`, where the NES (7-pin) Arkanoid controller + // reports -- the Famicom version's `$4016`/`$4017` split is a different + // device and is not what the MiSTer core models. + o.nes_mut().set_paddle(1, pos, fire); + } + for code in &d.genie { + o.nes_mut() + .add_genie_code(code) + .map_err(|e| format!("--genie {code:?}: {e}"))?; + } + Ok(()) +} + +/// Validate the device request, then attach it, or exit with the reason. +/// +/// One call in `main`, which sits at the pedantic line limit, for the same +/// reason [`arm_traces`] is one call. +fn attach_devices(o: &mut Oracle, args: &Args) { + if let Some(why) = device_error(args) { + eprintln!("{why}"); + usage(); + } + if let Err(e) = apply_devices(o, &args.devices) { + eprintln!("{e}"); + usage(); + } +} + +/// The manifest lines recording the devices, or the empty string when none +/// was attached -- which is what keeps every existing manifest byte-identical. +/// +/// Canonical forms (lower-case two-digit hex, upper-case codes) so the +/// recipe generator in `RustyNES_MiSTer` can turn each line straight back into +/// the flag that produced it. +fn device_block(d: &DeviceArgs) -> String { + // Lines collected and joined rather than `write!`-ed into one `String`, so + // there is no `fmt::Result` to discard (the reason `sha256_hex` is written + // the way it is). + let mut lines: Vec = Vec::new(); + if let Some(p) = d.four_score { + lines.push(format!( + "four_score = {:02x}:{:02x}:{:02x}:{:02x}\n", + p[0], p[1], p[2], p[3] + )); + } + if let Some((pos, fire)) = d.vaus { + lines.push(format!("vaus = {pos:02x}:{}\n", u8::from(fire))); + } + if !d.genie.is_empty() { + let codes: Vec = d.genie.iter().map(|c| c.to_ascii_uppercase()).collect(); + lines.push(format!("genie = {}\n", codes.join(","))); + } + lines.concat() } /// The `--ppu-state-trace` capacity, refused outside a usable range. @@ -129,6 +325,8 @@ fn usage() -> ! { \x20 [--inject-instructions N] [--inject-hold N]\n\ \x20 [--inject-nmi-at N] [--inject-irq-at N]\n\ \x20 [--press-start A:B]\n\ + \x20 [--four-score P1:P2:P3:P4] [--vaus POS:FIRE]\n\ + \x20 [--genie CODE]...\n\ \x20 [--ppu-state-trace CAP --pst-frames A:B\n\ \x20 [--pst-scanlines A:B] [--pst-dots A:B]]" ); @@ -185,6 +383,7 @@ fn parse_args() -> Args { let (mut inject_instructions, mut inject_hold) = (0u64, 1u64); let (mut inject_nmi_at, mut inject_irq_at) = (None, None); let mut press_start: Option<(u64, u64)> = None; + let mut devices = DeviceArgs::default(); let mut i = 0; // Every value-taking arm below advances `i` by TWO, not one: this loop has @@ -305,6 +504,27 @@ fn parse_args() -> Args { pst_dots = Some(parse_pair_u16(need(i)).unwrap_or_else(|| usage())); i += 2; } + // v3.2.0 (item 8). A malformed spec EXITS rather than being + // ignored: an unparsed device is a golden of a console with nothing + // plugged in, under a recipe claiming otherwise. + "--four-score" => { + devices.four_score = Some(parse_four_score_spec(need(i)).unwrap_or_else(|| { + eprintln!("--four-score wants P1:P2:P3:P4, four two-digit hex bytes"); + usage() + })); + i += 2; + } + "--vaus" => { + devices.vaus = Some(parse_vaus_spec(need(i)).unwrap_or_else(|| { + eprintln!("--vaus wants POS:FIRE, a two-digit hex position and 0 or 1"); + usage() + })); + i += 2; + } + "--genie" => { + devices.genie.push(need(i).clone()); + i += 2; + } "--checkpoint-interval" => { checkpoint_interval = need(i).parse().unwrap_or_else(|_| usage()); if checkpoint_interval == 0 { @@ -350,6 +570,7 @@ fn parse_args() -> Args { inject_nmi_at, inject_irq_at, inject_hold, + devices, } } @@ -880,6 +1101,7 @@ fn main() { std::fs::create_dir_all(&args.out).expect("create out dir"); let mut o = Oracle::new(&rom, args.seed).unwrap_or_else(|e| panic!("parse rom: {e}")); + attach_devices(&mut o, &args); // The power-on work RAM, captured BEFORE a single cycle runs. // @@ -975,7 +1197,9 @@ fn main() { (0, 0) }; - let mode_block = run_mode_block(&args, calls, frames_actual); + // The device lines ride in the mode block: they describe the run, and an + // empty block leaves every pre-v3.2.0 manifest byte-identical. + let mode_block = run_mode_block(&args, calls, frames_actual) + &device_block(&args.devices); let manifest = format!( "rom = {}\n\ @@ -1068,6 +1292,7 @@ mod tests { inject_nmi_at: None, inject_irq_at: None, inject_hold: 1, + devices: super::DeviceArgs::default(), }; let mut args = base(); @@ -1186,3 +1411,198 @@ mod tests { ); } } + +/// v3.2.0 (item 8): the device and cheat stimulus flags. +/// +/// The rules are tested through the REAL parsers and validator, and the +/// attach path through a real `Oracle` running a real ROM -- a test that only +/// checked the parsed `DeviceArgs` would pass while `apply_devices` attached +/// nothing, which is the "armed but empty" shape this exporter refuses +/// everywhere else. +#[cfg(test)] +mod device_tests { + use super::{ + Args, DeviceArgs, apply_devices, device_block, device_error, parse_four_score_spec, + parse_vaus_spec, + }; + + fn args(devices: DeviceArgs) -> Args { + Args { + #[cfg(feature = "ppu-state-trace")] + ppu_state_trace: None, + rom: std::path::PathBuf::from("/dev/null"), + out: std::path::PathBuf::from("/tmp"), + seed: 0, + frames: 1, + press_start: None, + boot_trace: None, + irq_trace: None, + fetch_trace: None, + apu_trace: None, + checkpoint_interval: 4096, + inject_instructions: 0, + inject_nmi_at: None, + inject_irq_at: None, + inject_hold: 1, + devices, + } + } + + #[test] + fn four_score_spec_takes_exactly_four_two_digit_hex_bytes() { + assert_eq!( + parse_four_score_spec("01:82:44:a8"), + Some([0x01, 0x82, 0x44, 0xA8]) + ); + assert_eq!( + parse_four_score_spec("FF:00:0f:F0"), + Some([0xFF, 0x00, 0x0F, 0xF0]) + ); + for bad in [ + "", + "01:82:44", + "01:82:44:a8:00", + "1:82:44:a8", + "001:82:44:a8", + "0g:82:44:a8", + ] { + assert_eq!(parse_four_score_spec(bad), None, "accepted {bad:?}"); + } + } + + #[test] + fn vaus_spec_takes_a_position_and_a_fire_bit() { + assert_eq!(parse_vaus_spec("a7:1"), Some((0xA7, true))); + assert_eq!(parse_vaus_spec("00:0"), Some((0x00, false))); + for bad in ["", "a7", "a7:2", "a7:", "7:1", "a7:1:0", "zz:1"] { + assert_eq!(parse_vaus_spec(bad), None, "accepted {bad:?}"); + } + } + + #[test] + fn incoherent_device_requests_are_refused() { + let fs = Some([1, 2, 3, 4]); + let mut a = args(DeviceArgs { + four_score: fs, + ..DeviceArgs::default() + }); + assert!(device_error(&a).is_none(), "a Four Score alone is coherent"); + a.press_start = Some((1, 2)); + assert!( + device_error(&a).is_some_and(|e| e.contains("--press-start")), + "Four Score + press-start" + ); + + let both = args(DeviceArgs { + four_score: fs, + vaus: Some((0x80, false)), + genie: Vec::new(), + }); + assert!(device_error(&both).is_some_and(|e| e.contains("four player"))); + + let bad_code = args(DeviceArgs { + genie: vec!["WXIOPO".to_owned()], + ..DeviceArgs::default() + }); + assert!(device_error(&bad_code).is_some_and(|e| e.contains("WXIOPO"))); + + // SXIOPO and SXIOPOZZ decode to the SAME address ($91D9); the second + // would replace the first inside the oracle. + let dup = args(DeviceArgs { + genie: vec!["SXIOPO".to_owned(), "SXIOPOZZ".to_owned()], + ..DeviceArgs::default() + }); + assert!(device_error(&dup).is_some_and(|e| e.contains("$91D9"))); + + let ok = args(DeviceArgs { + genie: vec!["SXIOPO".to_owned(), "YYKPOYZZ".to_owned()], + vaus: Some((0x10, true)), + ..DeviceArgs::default() + }); + assert!(device_error(&ok).is_none()); + } + + #[test] + fn no_device_adds_no_manifest_line_and_each_device_adds_its_own() { + assert_eq!(device_block(&DeviceArgs::default()), ""); + let d = DeviceArgs { + four_score: Some([0x01, 0x82, 0x44, 0xA8]), + vaus: None, + genie: vec!["sxiopo".to_owned(), "YYKPOYZZ".to_owned()], + }; + assert_eq!( + device_block(&d), + "four_score = 01:82:44:a8\ngenie = SXIOPO,YYKPOYZZ\n" + ); + let v = DeviceArgs { + vaus: Some((0xA7, true)), + ..DeviceArgs::default() + }; + assert_eq!(device_block(&v), "vaus = a7:1\n"); + } + + /// A 16 KiB NROM that strobes the pads and stores the first `$4016` and + /// `$4017` reads at `$00`/`$01`, then spins. + fn strobe_rom() -> Vec { + let mut rom = vec![0u8; 16 + 16384 + 8192]; + rom[..4].copy_from_slice(b"NES\x1a"); + rom[4] = 1; + rom[5] = 1; + let code = [ + 0xA9, 0x01, 0x8D, 0x16, 0x40, // LDA #1 ; STA $4016 + 0xA9, 0x00, 0x8D, 0x16, 0x40, // LDA #0 ; STA $4016 + 0xAD, 0x16, 0x40, 0x85, 0x00, // LDA $4016 ; STA $00 + 0xAD, 0x17, 0x40, 0x85, 0x01, // LDA $4017 ; STA $01 + 0x4C, 0x14, 0x80, // JMP $8014 (itself) + ]; + rom[16..16 + code.len()].copy_from_slice(&code); + let v = 16 + 16384 - 6; + rom[v..v + 6].copy_from_slice(&[0x00, 0x80, 0x00, 0x80, 0x00, 0x80]); + rom + } + + fn run(d: &DeviceArgs) -> (u8, u8, rustynes_cosim::Oracle) { + let mut o = rustynes_cosim::Oracle::new(&strobe_rom(), 0).expect("rom"); + apply_devices(&mut o, d).expect("attach"); + o.advance_frames(2); + let ram = o.nes().bus().ram_bytes(); + (ram[0], ram[1], o) + } + + #[test] + fn the_devices_actually_reach_the_emulated_ports() { + // Control: nothing attached. Open bus $40 from the operand's high byte, + // D0 = 0 from two unpressed pads. + let (p1, p2, _) = run(&DeviceArgs::default()); + assert_eq!((p1, p2), (0x40, 0x40)); + + // Four Score: pad 1's A (bit 0) and pad 2's A are the first bits out. + let (p1, p2, o) = run(&DeviceArgs { + four_score: Some([0x01, 0x01, 0x00, 0x00]), + ..DeviceArgs::default() + }); + assert!(o.nes().four_score()); + assert_eq!((p1, p2), (0x41, 0x41)); + + // Vaus on $4017: knob $A7 has its MSb set, which goes out INVERTED on + // D4 (so 0), and fire held is D3 = 1; D0 is not driven. $4016 is + // still the standard pad. + let (p1, p2, _) = run(&DeviceArgs { + vaus: Some((0xA7, true)), + ..DeviceArgs::default() + }); + assert_eq!((p1, p2), (0x40, 0x48)); + let (_, p2, _) = run(&DeviceArgs { + vaus: Some((0x27, false)), + ..DeviceArgs::default() + }); + assert_eq!(p2, 0x50, "MSb clear goes out as D4 = 1"); + + // Game Genie: every code is active, none silently dropped. + let (_, _, o) = run(&DeviceArgs { + genie: vec!["SXIOPO".to_owned(), "YYKPOYZZ".to_owned()], + ..DeviceArgs::default() + }); + assert_eq!(o.nes().genie_codes().count(), 2); + } +} diff --git a/docs/mister.md b/docs/mister.md index e8816070..c623b60f 100644 --- a/docs/mister.md +++ b/docs/mister.md @@ -761,6 +761,35 @@ Requested and actual are recorded separately because they can legitimately diffe a ROM that jams stops advancing frames, and the export still succeeds. What must never happen is the manifest claiming a frame count that was not simulated. +### Input-device and cheat stimuli (v3.2.0) + +Item 8 of v3.2.0 gives the MiSTer core a Four Score, an Arkanoid (Vaus) paddle +and Game Genie style cheats, and each needs a golden with the same device +attached on this side. Three exporter flags attach them for the whole run: + +| Flag | Attaches | Manifest line | +|---|---|---| +| `--four-score P1:P2:P3:P4` | the Four Score, with four pads' button bytes (two hex digits each, `Buttons` bit order) | `four_score = 01:82:44:a8` | +| `--vaus POS:FIRE` | an NES (7-pin) Arkanoid controller on `$4017`: knob position (two hex digits) and fire (`0`/`1`) | `vaus = a7:1` | +| `--genie CODE` (repeatable) | a 6- or 8-letter Game Genie code | `genie = SXIOPO,YYKPOYZZ` | + +They call the emulator's existing models (`Nes::set_four_score`, +`Nes::set_paddle`, `Nes::add_genie_code`) and change no emulated behaviour of +their own, so `EMULATION_EPOCH` does not move. The manifest gains a line only +when a flag is given, so every earlier manifest is byte-identical. The inputs +are held **constant** for the run: a frame-windowed schedule would need the +DUT's testbench to reproduce the frame boundaries, and a stimulus ROM that reads +the device every frame exercises every bit of it anyway. + +Three combinations are refused rather than exported: `--four-score` with +`--press-start` (both write port 1's buttons), `--four-score` with `--vaus` (the +NESdev Arkanoid page: "No version is compatible with four player adapters"), and +two codes patching one address (the emulator keeps one code per address, so the +manifest would list a code that never applied). The parsers, the refusals, the +manifest lines and the attach path are tested in the binary's `device_tests`; +the attach test runs a ROM that strobes the ports and reads them back, so a flag +that parsed and attached nothing fails it. + ## The null-DUT gate `crates/rustynes-cosim/tests/null_dut_self_diff.rs` is rung 0's half of the From ffef40d51d920bb6f21dbc6ed1d7b9d4dec16b48 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 8 Oct 2026 23:37:06 -0400 Subject: [PATCH 025/104] feat(mappers): add mapper 252 (Waixing VRC4 clone, CHR-RAM pages 6 and 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapper 252 carries Waixing's Chinese localization of Namcot's Sangokushi: Chuugen no Hasha (San Guo Zhi). Written from the vendored NESdev page INES_Mapper_252; no reference-emulator source was read. The page: "a clone of the Konami VRC4, with the chip's A0 connected to CPU A2 and the chip's A1 connected to CPU A3 (VRC4e, Mapper 23 submapper 2). It has 8 KiB of PRG-RAM at CPU $6000-$7FFF, and 1 KiB CHR pages 6 and 7 are CHR-RAM." Implementation (m252_waixing_vrc4.rs): the project's own Vrc4 (no provenance header) built as mapper 23 submapper 2, which supplies banking, mirroring, the IRQ and the 8 KiB PRG-RAM, wrapped with 2 KiB of CHR-RAM. A 1 KiB PPU slot whose CHR bank register holds exactly 6 or 7 reads and writes the RAM; every other value is CHR-ROM, and a pattern write to a ROM page is dropped. A new pub(crate) Vrc4::chr_reg exposes the slot registers. Save state: own version byte, the 2 KiB, then the Vrc4 blob (validated before the RAM is copied). Mapper 253's model in waixing.rs is not reused. The page names the page numbers but not how many register bits take part. A whole-value match is the plain reading, and the game rules out the alternative of matching the low three bits: its map screen sets slots 6/7 to ROM pages 14 and 23 for the portraits, which a 3-bit match turns into RAM. Booting it exposed a VRC4 defect fixed in the previous commit (the IRQ mode bit was inverted, so the text-box split fired two lines into the frame). Harness: InputScript::HeldStart becomes InputScript::Held, a list of (frame, buttons) presses each held N frames, because San Guo Zhi's questions take A. Fudou Myouou Den's 207 test moves to it unchanged (its snapshot is byte-identical). Verification - Red first: the four parse-driven tests failed with UnsupportedMapper(252) before the dispatch arm; 4 pass now (VRC4e decode, RAM pages vs ROM pages, PRG-RAM and IRQ via the core, state round trip). - San Guo Zhi (Ch) (the clean GoodNES dump, not [b1]/[b2]) boots through the intro and difficulty menu to the scenario map; with A it asks the first personality question in the text box under the map ("是好好先生吗", 是 / 不是), legible, with the portraits above it from ROM. That is the snapshot frame (f3800). The first prompt alone is not evidence: its glyphs are pre-rendered in ROM pages 6/7 and draw the same with no RAM, which is why the script answers it. - Mutation: no CHR-RAM, RAM pages 4/5 instead of 6/7, and a 3-bit match each moved the f3800 frame (9415df2e8ce183b4 -> c3648cfaa216eece for the first two) and failed unit tests ("ROM page 5 is not writable" left 0 right 170; decode test left 0 right 127 for the 3-bit match). - D23 row, screenshot and external_coverage snapshot committed. Tier: Curated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 8 + crates/rustynes-mappers/src/m021_vrc4.rs | 7 + .../rustynes-mappers/src/m252_waixing_vrc4.rs | 294 ++++++++++++++++++ crates/rustynes-mappers/src/tier.rs | 6 +- .../rustynes-test-harness/tests/common/mod.rs | 34 +- .../tests/external_extended.rs | 38 ++- ...er_252_WaixingVRC4_San_Guo_Zhi_Ch_zip.snap | 13 + ...l_extended__extended_m252_san_guo_zhi.snap | 12 + docs/compatibility.md | 1 + docs/mappers.md | 3 +- .../San Guo Zhi (Ch).png | Bin 0 -> 5037 bytes 11 files changed, 391 insertions(+), 25 deletions(-) create mode 100644 crates/rustynes-mappers/src/m252_waixing_vrc4.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_252_WaixingVRC4_San_Guo_Zhi_Ch_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m252_san_guo_zhi.snap create mode 100644 screenshots/external/mapper-252-WaixingVRC4/San Guo Zhi (Ch).png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 69b4e337..967572bc 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -133,6 +133,7 @@ mod m241_bxrom241; mod m244_cne_decathlon; mod m246_fong_shen_bang246; mod m250_nitra250; +mod m252_waixing_vrc4; mod m268_bmc_coolboy; mod m513_sachen_9602; mod mapper; @@ -244,6 +245,7 @@ pub use m241_bxrom241::Bxrom241; pub use m244_cne_decathlon::Decathlon244; pub use m246_fong_shen_bang246::FongShenBang246; pub use m250_nitra250::Nitra250; +pub use m252_waixing_vrc4::Waixing252; pub use m268_bmc_coolboy::new_m268; pub use m513_sachen_9602::new_m513; pub use mapper::{ @@ -1596,6 +1598,12 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { TaitoX1005::new_m207(prg_rom, chr_rom) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // 252: Waixing VRC4e clone with CHR-RAM pages 6 and 7 (*San Guo Zhi*; + // `m252_waixing_vrc4.rs`). + 252 => Box::new( + Waixing252::new(prg_rom, chr_rom, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/m021_vrc4.rs b/crates/rustynes-mappers/src/m021_vrc4.rs index 0553f120..e32a9c24 100644 --- a/crates/rustynes-mappers/src/m021_vrc4.rs +++ b/crates/rustynes-mappers/src/m021_vrc4.rs @@ -493,6 +493,13 @@ impl Mapper for Vrc4 { } impl Vrc4 { + /// The 1 KiB CHR bank register for PPU slot `slot` (`0..8`, one per + /// 1 KiB of `$0000-$1FFF`). For boards that wrap the VRC4 and intercept + /// particular bank numbers (mapper 252's CHR-RAM pages, v3.2.0). + pub(crate) fn chr_reg(&self, slot: usize) -> u8 { + self.chr[slot & 7] + } + /// Bytes the v2 tail adds: the 8 KiB PRG-RAM, plus the 8 KiB CHR-RAM /// when the cartridge has no CHR-ROM. Derived from the loaded ROM, so a /// save and its load (same ROM, checked by the `.rns` hash tag) agree. diff --git a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs new file mode 100644 index 00000000..f2ec20a1 --- /dev/null +++ b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Mapper 252: Waixing's VRC4-clone board for *San Guo Zhi* (v3.2.0). +//! +//! Written from the vendored `NESdev` page `INES_Mapper_252`; no +//! reference-emulator source was read. The board carries Waixing's Chinese +//! localization of Namcot's *Sangokushi: Chuugen no Hasha*. Per the page it +//! "uses a clone of the Konami VRC4, with the chip's A0 connected to CPU A2 +//! and the chip's A1 connected to CPU A3 (`VRC4e`, Mapper 23 submapper 2). It +//! has 8 KiB of PRG-RAM at CPU `$6000-$7FFF`, and 1 KiB CHR pages 6 and 7 are +//! CHR-RAM." +//! +//! So the board is the project's own [`Vrc4`] (`m021_vrc4.rs`) wired as +//! mapper 23 submapper 2, which already supplies the PRG and CHR banking, the +//! mirroring register, the IRQ counter and the 8 KiB PRG-RAM, plus 2 KiB of +//! CHR-RAM: any 1 KiB PPU slot whose CHR bank register holds 6 reads and +//! writes the first 1 KiB of that RAM, and one holding 7 the second, instead +//! of CHR-ROM pages 6 and 7. That is the same "these bank numbers are RAM" +//! arrangement the page compares with the Waixing MMC3 boards (74, 192, 194, +//! 195). +//! +//! Mapper 253 is "the same, except that it's 1 KiB CHR pages 4 and 5 that are +//! CHR-RAM". The project's mapper 253 (`waixing.rs`) is an older, separately +//! derived model and is deliberately not reused here. +//! +//! The page states only the bank numbers, not how many bits of the VRC4's CHR +//! register take part in the match. This model compares the whole register +//! value (`== 6` / `== 7`), the plain reading of "pages 6 and 7"; *San Guo +//! Zhi* boots and plays under it. + +use crate::cartridge::Mirroring; +use crate::m021_vrc4::Vrc4; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError}; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const CHR_BANK_1K: usize = 0x0400; +/// The two CHR bank numbers that select CHR-RAM instead of CHR-ROM. +const RAM_PAGES: [u8; 2] = [6, 7]; +const SAVE_STATE_VERSION: u8 = 1; + +/// Mapper 252 (Waixing VRC4 clone with CHR-RAM pages 6 and 7). +pub struct Waixing252 { + inner: Vrc4, + /// 2 KiB of CHR-RAM: pages 6 and 7. + chr_ram: Box<[u8]>, +} + +impl Waixing252 { + /// Construct the board around a `VRC4e` (mapper 23 submapper 2) core. + /// + /// # Errors + /// + /// As [`Vrc4::new`]: PRG a non-zero multiple of 8 KiB, CHR-ROM a multiple + /// of 1 KiB. + pub fn new( + prg_rom: Box<[u8]>, + chr_rom: Box<[u8]>, + mirroring: Mirroring, + ) -> Result { + Ok(Self { + inner: Vrc4::new(prg_rom, chr_rom, 23, 2, mirroring)?, + chr_ram: vec![0u8; 2 * CHR_BANK_1K].into_boxed_slice(), + }) + } + + /// The CHR-RAM offset for pattern address `addr`, when its slot's bank is + /// page 6 or 7. + fn ram_offset(&self, addr: u16) -> Option { + let slot = usize::from((addr >> 10) & 0x07); + let bank = self.inner.chr_reg(slot); + RAM_PAGES + .iter() + .position(|&p| p == bank) + .map(|page| page * CHR_BANK_1K + usize::from(addr & 0x03FF)) + } +} + +impl Mapper for Waixing252 { + fn caps(&self) -> MapperCaps { + self.inner.caps() + } + + fn sram(&self) -> &[u8] { + self.inner.sram() + } + + fn sram_mut(&mut self) -> &mut [u8] { + self.inner.sram_mut() + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + self.inner.cpu_read_unmapped(addr) + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + self.inner.cpu_read(addr) + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + self.inner.cpu_write(addr, value); + } + + fn chr_phys(&self, addr: u16) -> Option { + // CHR-RAM pages are content-hashed by the HD-pack path; ROM pages + // fall back to it too, as the wrapped VRC4 does. + let _ = addr; + None + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr < 0x2000 + && let Some(off) = self.ram_offset(addr) + { + return self.chr_ram[off]; + } + self.inner.ppu_read(addr) + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + if addr < 0x2000 { + if let Some(off) = self.ram_offset(addr) { + self.chr_ram[off] = value; + } + // Pages other than 6 and 7 are CHR-ROM: a write there goes nowhere. + return; + } + self.inner.ppu_write(addr, value); + } + + fn notify_cpu_cycle(&mut self) { + self.inner.notify_cpu_cycle(); + } + + fn irq_pending(&self) -> bool { + self.inner.irq_pending() + } + + fn irq_acknowledge(&mut self) { + self.inner.irq_acknowledge(); + } + + fn current_mirroring(&self) -> Mirroring { + self.inner.current_mirroring() + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = self.inner.debug_info(); + info.mapper_id = 252; + info.name = "Waixing VRC4 clone, CHR-RAM pages 6/7 (252)".into(); + info.extra.push(( + "CHR-RAM slots".into(), + format!( + "{:?}", + (0..8) + .filter(|&s| RAM_PAGES.contains(&self.inner.chr_reg(s))) + .collect::>() + ), + )); + info + } + + fn save_state(&self) -> Vec { + let inner = self.inner.save_state(); + let mut out = Vec::with_capacity(1 + self.chr_ram.len() + inner.len()); + out.push(SAVE_STATE_VERSION); + out.extend_from_slice(&self.chr_ram); + out.extend_from_slice(&inner); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + let head = 1 + self.chr_ram.len(); + if data.len() < head { + return Err(MapperError::WrongLength { + expected: head, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + // The inner blob validates itself before anything is written to the + // core; the CHR-RAM is copied only once it has. + self.inner.load_state(&data[head..])?; + self.chr_ram.copy_from_slice(&data[1..head]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + /// A mapper 252 image through `crate::parse`: 256 KiB PRG, 128 KiB CHR + /// whose every 1 KiB page opens with its index. + fn board() -> Box { + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = 16; + v[5] = 16; + v[6] = 0xC1; // 252 = 0xFC: low nibble 0xC, vertical + v[7] = 0xF0; + for b in 0..32u8 { + let mut bank = vec![0u8; 0x2000]; + bank[0] = b; + v.extend_from_slice(&bank); + } + for p in 0..128u8 { + let mut page = vec![0u8; CHR_BANK_1K]; + page[0] = p; + v.extend_from_slice(&page); + } + parse(&v) + .unwrap_or_else(|e| panic!("mapper 252 must parse: {e:?}")) + .1 + } + + /// Write a CHR bank register through the `VRC4e` decode: chip A0 = CPU A2, + /// chip A1 = CPU A3. `base` is `$B000`..`$E000`, `second` picks the odd + /// slot of the pair. + fn chr(m: &mut dyn Mapper, base: u16, second: bool, value: u8) { + let a1 = if second { 0x08 } else { 0 }; + m.cpu_write(base | a1, value & 0x0F); // low nibble (chip A0 = 0) + m.cpu_write(base | a1 | 0x04, value >> 4); // high nibble (chip A0 = 1) + } + + #[test] + fn m252_vrc4e_decode_selects_chr_rom_pages() { + let mut m = board(); + chr(m.as_mut(), 0xB000, false, 0x25); + chr(m.as_mut(), 0xB000, true, 0x31); + chr(m.as_mut(), 0xE000, true, 0x7F); + assert_eq!(m.ppu_read(0x0000), 0x25); + assert_eq!(m.ppu_read(0x0400), 0x31, "A3 selects the second slot"); + assert_eq!(m.ppu_read(0x1C00), 0x7F); + } + + #[test] + fn m252_pages_6_and_7_are_chr_ram_and_others_are_rom() { + let mut m = board(); + chr(m.as_mut(), 0xB000, false, 6); + chr(m.as_mut(), 0xB000, true, 7); + chr(m.as_mut(), 0xC000, false, 5); + chr(m.as_mut(), 0xC000, true, 4); + m.ppu_write(0x0010, 0xAA); // slot 0 -> page 6 (RAM) + m.ppu_write(0x0410, 0xBB); // slot 1 -> page 7 (RAM) + m.ppu_write(0x0810, 0xCC); // slot 2 -> page 5 (ROM: ignored) + assert_eq!(m.ppu_read(0x0010), 0xAA); + assert_eq!(m.ppu_read(0x0410), 0xBB); + assert_eq!(m.ppu_read(0x0810), 0x00, "ROM page 5 is not writable"); + assert_eq!(m.ppu_read(0x0800), 5, "ROM page 5"); + assert_eq!(m.ppu_read(0x0C00), 4, "ROM page 4 (253's RAM, not 252's)"); + // The same RAM page seen through another slot. + chr(m.as_mut(), 0xE000, false, 7); + assert_eq!(m.ppu_read(0x1810), 0xBB, "page 7 is one 1 KiB of RAM"); + // A bank number that only shares low bits with 6 is ROM. + chr(m.as_mut(), 0xB000, false, 0x16); + assert_eq!(m.ppu_read(0x0000), 0x16); + } + + #[test] + fn m252_prg_ram_and_irq_come_from_the_vrc4() { + let mut m = board(); + m.cpu_write(0x6123, 0x5A); + assert_eq!(m.cpu_read(0x6123), 0x5A, "8 KiB PRG-RAM"); + assert_eq!(m.sram().len(), 0x2000); + // IRQ latch $FF, enable, M = 1 (cycle mode, `VRC_IRQ`): fires on the + // next CPU cycle. Control is at chip A1 = CPU A3. + m.cpu_write(0xF000, 0x0F); + m.cpu_write(0xF004, 0x0F); + m.cpu_write(0xF008, 0x06); + assert!(!m.irq_pending()); + m.notify_cpu_cycle(); + assert!(m.irq_pending()); + assert_eq!(m.caps(), MapperCaps::CYCLE_IRQ); + } + + #[test] + fn m252_state_round_trips_with_chr_ram() { + let mut a = board(); + chr(a.as_mut(), 0xD000, false, 6); + a.ppu_write(0x1000, 0x42); + a.cpu_write(0x6000, 0x99); + let blob = a.save_state(); + let mut b = board(); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.ppu_read(0x1000), 0x42); + assert_eq!(b.cpu_read(0x6000), 0x99); + assert!(b.load_state(&blob[..10]).is_err()); + } +} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 16ad66fd..4c772e70 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -142,7 +142,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // `docs/compatibility.md`). A separate arm so each release's batch // reads as one list; the bodies match on purpose. #[allow(clippy::match_same_arms)] - 158 | 172 | 173 | 207 => Some(MapperTier::Curated), + 158 | 172 | 173 | 207 | 252 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -250,8 +250,8 @@ mod tests { 178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". - 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, - 207, // v3.2.0 + 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, 207, + 252, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index c07c7012..a2de06d4 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -338,13 +338,15 @@ pub mod external { free_run: u32, checkpoints: &'static [u32], }, - /// v3.2.0 — START HELD for `hold` frames from each frame index in - /// `presses` (0-based, ascending), idle otherwise, for `frames` frames; - /// one checkpoint at the end. For games whose menus ignore a one-frame - /// tap (*Fudou Myouou Den*'s START / CONTINUE menu reads the button - /// across several frames), so no single-tap script reaches gameplay. - HeldStart { - presses: &'static [u32], + /// v3.2.0 — each `(frame, buttons)` in `presses` holds those buttons + /// for `hold` frames from that frame index (0-based), idle otherwise, + /// for `frames` frames; one checkpoint at the end. For games whose + /// menus ignore a one-frame tap (*Fudou Myouou Den*'s START / + /// CONTINUE menu reads the button across several frames) or need a + /// button other than START (*San Guo Zhi*'s questions take A), which + /// no other script can reach. + Held { + presses: &'static [(u32, Buttons)], hold: u32, frames: u32, }, @@ -357,7 +359,7 @@ pub mod external { #[must_use] pub const fn total_frames(&self) -> u32 { match *self { - Self::IdleOnly { frames } | Self::HeldStart { frames, .. } => frames, + Self::IdleOnly { frames } | Self::Held { frames, .. } => frames, Self::StartTap { idle_pre, idle_post, @@ -969,21 +971,17 @@ pub mod external { maybe_emit(frame_counter, &nes); } } - InputScript::HeldStart { + InputScript::Held { presses, hold, frames, } => { for f in 0..frames { - let held = presses.iter().any(|&p| f >= p && f < p + hold); - nes.set_buttons( - 0, - if held { - Buttons::START - } else { - Buttons::empty() - }, - ); + let held = presses + .iter() + .filter(|&&(p, _)| f >= p && f < p + hold) + .fold(Buttons::empty(), |acc, &(_, b)| acc | b); + nes.set_buttons(0, held); pulse_vs_coin!(); nes.run_frame(); samples.extend(nes.drain_audio()); diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index a8222374..ca3c4ddd 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -39,6 +39,7 @@ mod common; use common::external::{InputScript, run_capture, snapshot_text}; +use rustynes_core::Buttons; /// Default script: 600 frames idle (no input), 10 s @ NTSC 60 Hz — /// past the title-screen ramp-up for the bulk of these games. @@ -1459,11 +1460,11 @@ fn extended_m158_alien_syndrome() { /// *Fudou Myouou Den*: START held three frames at f1900 (skips the intro to /// the START / CONTINUE menu) and again at f2100 (starts the game), then play /// to f3000, in the first stage with the status bar under the playfield. The -/// menu ignores one-frame taps, so `HeldStart`. Gameplay is where mapper +/// menu ignores one-frame taps, so `Held`. Gameplay is where mapper /// 207's CHR A17 nametable select shows: the title screen and the intro draw /// identically with it forced to page 0, and this frame does not. -const FUDOU_IN_PLAY: InputScript = InputScript::HeldStart { - presses: &[1900, 2100], +const FUDOU_IN_PLAY: InputScript = InputScript::Held { + presses: &[(1900, Buttons::START), (2100, Buttons::START)], hold: 3, frames: 3000, }; @@ -1476,3 +1477,34 @@ fn extended_m207_fudou_myouou_den() { "extended_m207_fudou_myouou_den", ); } + +/// *San Guo Zhi* (mapper 252): START held at f1500-f3300 passes the intro, +/// the difficulty menu and the first prompt to the scenario map, and A at +/// f3600 answers it, so at f3800 the text box under the map asks the first +/// personality question. That box sits behind a VRC4 scanline-IRQ bank split +/// and is drawn from the two CHR-RAM pages; the portraits above it come from +/// CHR-ROM. The first prompt is not enough: its glyphs are pre-rendered in +/// CHR-ROM pages 6 and 7, so it draws the same with no CHR-RAM at all, and +/// only the questions after it are composed in RAM. +const SAN_GUO_ZHI_QUESTION: InputScript = InputScript::Held { + presses: &[ + (1500, Buttons::START), + (1800, Buttons::START), + (2100, Buttons::START), + (2400, Buttons::START), + (3000, Buttons::START), + (3300, Buttons::START), + (3600, Buttons::A), + ], + hold: 3, + frames: 3800, +}; + +#[test] +fn extended_m252_san_guo_zhi() { + check( + "mapper-252-WaixingVRC4/San Guo Zhi (Ch).zip", + SAN_GUO_ZHI_QUESTION, + "extended_m252_san_guo_zhi", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_252_WaixingVRC4_San_Guo_Zhi_Ch_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_252_WaixingVRC4_San_Guo_Zhi_Ch_zip.snap new file mode 100644 index 00000000..26139a89 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_252_WaixingVRC4_San_Guo_Zhi_Ch_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-252-WaixingVRC4/San Guo Zhi (Ch).zip +rom_sha256=c3fbdb2cd86354f8471ffa114e9cf0e48a916f88833cd48a142f7fc10b52e827 +frames=1141 +fb_bytes=245760 +cycles=33949777 +audio_samples=836505 +audio_fnv1a64=da7b8a5e7d5c88c5 +checkpoint f900 fb_fnv1a64=c1ff7e3d384ce207 +checkpoint f1100 fb_fnv1a64=f8964d09c55bd767 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m252_san_guo_zhi.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m252_san_guo_zhi.snap new file mode 100644 index 00000000..53f29e54 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m252_san_guo_zhi.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-252-WaixingVRC4/San Guo Zhi (Ch).zip +rom_sha256=c3fbdb2cd86354f8471ffa114e9cf0e48a916f88833cd48a142f7fc10b52e827 +frames=3800 +fb_bytes=245760 +cycles=113136123 +audio_samples=2787656 +audio_fnv1a64=b0cbd24a83d38abb +checkpoint f3800 fb_fnv1a64=9415df2e8ce183b4 diff --git a/docs/compatibility.md b/docs/compatibility.md index e3792792..579d96ab 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -377,3 +377,4 @@ in `crates/rustynes-test-harness/tests/external_extended.rs`. | 173 | Xiao Ma Li (Ch) [!] | `103e27a9b2e2c029fee9a221e2ffe35b5b0b4a3e529a4df88fd6d71f21cf56a4` | 600 | `screenshots/external/mapper-173-TXC-05-00002-010/Xiao Ma Li (Ch) [!].png` | | 158 | Alien Syndrome (USA) (Unl) | `d676ef6f7aa3b042ae5a0c95a7f07fabb4c3a5fccb3767ae1950713e51189a47` | 2400 (attract demo, in play) | `screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png` | | 207 | Fudou Myouou Den (Japan) (En) (1.0) (translation, 207 header) | `e33482a375923d692bb5ddd3be71dab63de6a786ec311e8dfc32417a19e48fb3` | 3000 (START held at 1900 and 2100; stage 1 in play) | `screenshots/external/mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).png` | +| 252 | San Guo Zhi (Ch) | `659837c9b4ffbc6089aa191e6fdc238f50c1c2116a04b993381a5022444cb652` | 3800 (START held at 1500-3300, A at 3600; first personality question on the map) | `screenshots/external/mapper-252-WaixingVRC4/San Guo Zhi (Ch).png` | diff --git a/docs/mappers.md b/docs/mappers.md index 1a06a7ab..91a73343 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -852,7 +852,8 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 172 | — | Super Mega P-4070, JV001 (`INES_Mapper_172`, `INES_Mapper_136`) | Curated | — | `m172_m173_txc.rs`. The JV001's Input / Register / Output with CPU D0-D5 wired in reverse; Mode and Invert from CPU D5. `$8000-$FFFF` latches Output (CHR bank = Output & 3 in chip order) and sets mirroring from Invert (H when clear, V when set); the header's mirroring holds until then. PRG fixed 32 KiB. `$4100` reads drive D0-D5 only. *1991 Du Ma Racing* passes its protection check and plays. | | 173 | — | Idea-Tek ET-xx, 05-00002-010 (`INES_Mapper_173`) | Curated | — | Same module. P/R/Output 3 bits, S/C/V 1 bit; `$4100` reads `[xxxx SRRR]` with S XOR V, D4-D7 open bus. CHR bank = (Output & 1) \| (~V << 1), V read live. Hard-wired mirroring. *Xiao Ma Li*'s 8 KiB EPROM with `/PGM` on pin 27 is not modelled beyond wrapping (the page does not say what a disabled CHR read returns). The TXC *Mahjong Block* (CRC32 `0ACFC3CD`), commonly headered 173, is a mapper 132 board per both pages: as 173 it draws garbage, as the page predicts. | | 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | -| 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`HeldStart`: the menu ignores one-frame taps). | +| 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`Held`: the menu ignores one-frame taps). | +| 252 | — | Waixing VRC4 clone (`INES_Mapper_252`) | Curated | VRC4 (scanline / CPU) | `m252_waixing_vrc4.rs`: the project's `Vrc4` wired as mapper 23 submapper 2 (VRC4e: chip A0 = CPU A2, A1 = CPU A3) with its 8 KiB PRG-RAM and IRQ, plus 2 KiB of CHR-RAM: a 1 KiB slot whose bank register holds exactly 6 or 7 reads and writes RAM instead of CHR-ROM. The page names only the page numbers; a whole-value match is the plain reading, and *San Guo Zhi* rules out a 3-bit match (it would turn the portraits' ROM pages 14 and 23 into RAM). Booting it found the VRC4 IRQ mode-bit inversion described below. Mapper 253's older model in `waixing.rs` is not reused. | **VRC4 IRQ mode bit, corrected (v3.2.0).** `m021_vrc4.rs` (mappers 21, 23, 25) read bit 2 of the IRQ control register the wrong way round: `VRC_IRQ` diff --git a/screenshots/external/mapper-252-WaixingVRC4/San Guo Zhi (Ch).png b/screenshots/external/mapper-252-WaixingVRC4/San Guo Zhi (Ch).png new file mode 100644 index 0000000000000000000000000000000000000000..db868743f0399e357fe4bee508a2075254ce1f3c GIT binary patch literal 5037 zcmcIoc{CL6zkX*7jlIa)7o!?Nj44aLU(481X2_Oeh=ybv5?O{BW66@WBBBr_lr4;q zsI1ABwPY!>%!F)VnDO&F=bm%_xaZt^&pqe<^PcxS?>V3Ip67Gk=Y5_}yoI?5KaV62 z0D%AeIU_3oAp2YJ8_u=AE@#wt0U#W3-bmjj_|f8HofKCSk(ix)bE(t~uBlIz@fDYA zUE{$Mu(t*g^3I#~ZlsB99rx3~HR}7rI}{?RFYU!4zaY%qlJ} z?hR!${rHm`AXu8zx3 z8gWyD&-c!~aC~r09|*yzB)w;TvypQL6ua!+k|W|W4nsQR!QGT&k~uPGqNqPOE%)+t zWqb7}W3C@~5k>WkhY|C1dB?HMTe4Usj8D+vU-M^6b$J_05N{dKh+spS8|DN@j=0Ss zkkPB1Cz7d9cO9~xWxfy!1Erz-HnF@ELyy?}A8pIOE{&r75Hd`}Z$s=Bx zEE_8vn4M?%zgtt<2}6~=03?LwQ_o9Jc`DkZTvaPdq zAx@q!$zPFN7Ax9qp>MBukK#Rt9R*UZ%|UdVcXafyC$mm^oP&(0UrVs5V1Fi{HmBCx{iS(TJD2+;2d zT$e{JQRtJ_Q<9Z-*6`mTPswUHA@|MbqIs^23cm%VWU}?TqHJn=U6OBZKQ(Pm-xe#qcC0tV_ak_pAaW+zMfrSvG|5WekVidb$TdDx<%tDK(z`n0va z_M?(^reC=nVb*R8HN$+KNAIwx!u?To=nXPjkWAgk5&By+V!$}<|B`&U_GeNQd% z#M?IlWs`LXE>-w@M~$Jop*#2Emg$)6(#3fKekg9F4eT>wEAiYzrp7;Y5v3UnIM5&A znDH%6nnRoyx^8ch&DvOcyeZH_97dFn$r04waZqQIoHhh-xpJFdq6E_q$W=%dO;SgK66WhQ;d3(sjj(jB%Mdj2;MnjLCF_c%DOxB8g{6R^*Io}w(U zW%!9KA@cZ7UDQ%L59xWABIaeo;W3B7Y)u&BjAI7VbHUd>BU`Qn+{`MOSfh`{`SIh= zf8h(M|6HQqJWvyn{R3LEl0c3rhpr3t@x;HaH(j-4-s?5T5iD}W-}p;?58B{8XN+BT z3c5(mlruG5t;B{-28Z0gJ6;X@RhHzz}|ndK4|BrZa(x-uWVL6KL5{BVJ+@-rz&9?*I)8V1;e zEJeOCINAJF4k~G{sBV{puy8~fW0v%IcCL^Dh=(F<>G;&xgM!V2-17m%(Xz5;QVrtb z#XOPvb>P4PrfL!oGMk0p$8QWq!_R5%xhk+W=n=)~&agknw1ac?>8w&BV}06SCz`et z_mFIZ!rY-si=|F(-11yNC2wqPi(kQrKt>79g%nUIamy_J`0%dAJHEFYm5qjFL0!k8 zk$9IYJ`_C)rKZtHoqe}x7UCU$lWrD458Fh3^WO-w5V!@eD4p%4#TC&FuVhFgi%OAtt z4VDAarpvn&LE6G~h=&g36866G)go@4`>9G*sa{98p}^vDnwb_quFJ50?KpkX5dpx% ziW<``d(|+5DKGv?q8Ah*elUMx%lLRe6!x7eQRj zEF?M zDO;=N8szu-JB^yNuxBRg#tfN?euC0@4pY87*b7K>vBd1(@JofH%EL|92>90 z0bd5}sl5K>lXl7E_h&^w;W0J%s3Nu{PiAsY*7T+hBmM8tyY)R6`I|XTBr0TAKAP~x zF6&scLyjo)WF%yI_crfKNi=o}Uzu91-5Ts4i6^^?80ZK>Ew+(jZNb@u zm!G~R(zi)M5u@5vWfBLr1;|QSd-{t^s=3v#@cv0Wra3u(54GK$HrwlybXx4_wJg$G z+uSB!Z8BVk27lSAQFT{Po7y(3{(AqRYLDHD$|>wtbC+`yCnlU!It$G*CEsD|oX+kCRlb_m&I>7tI#g1iOy3*&R{e%ly#| z3Q;b|_Jr7+f%W&9!wYk9T0Z5|3-~#2iJx=yy=9V0p+^_F%?IBk^Hc75-e6;##3@BT zevu*UJ8}CkHj@?aw`dl!V(;QN1A$8%ewyEV^Q2Z|kQ8rlFIl+-@CaMKYE7FdsHmB7 zQi?|Qy-@VxG!Jmb&XcvTmadtCB`I0@(d z`q*ImZN04JLoj%sO0-5lq^E^b_U1m$wU%*)wG}ncXt(I6&U+!RdfKD54Y^l9z#+JZ z*1(y_tFY$dP`PQb4OXr6L^<@0rGof;cK>CWg=eT*q8I@*_T>HINq$JFJ<5fps@`r< z2{@Fxv;8;jOJcY<#KO4Qc9oaC_2R>0QqvZTp?7=a@v2}?$mJIGOTC%~d>9p836n|1 zgy$kt<0)lthJy6!ndG-@J_+OC(8L`^>>&{Gmp0)vMVPRCz-cM#iDrQ}WN03LIKqGG zoStx9LjWq)1odLFSZI3S+PITqF(x^kJ=N8;WbZsv`P=L6#eb}Yb9+@y*J0XfqxVn6 z(4{ezE5DbWLI6o5uSQJj(B)jq=Qa154wQrf17LHlS4*1x9JSmqQo})oS}SqoG;0Xv z*=W)^q%rodl4F}_;r&8=rt%29fq*vTgua+mgjG_kSPUu~GPJCYcASH|Y-45r>266b zh&@XHPo9bkqOh!gq%l7gsol4Kjn|ws7GweYLOVeWs`cWtprrKJl^dL!-cnEOyE*$L zyLm94K|Qu}BPMVInXRL2@$iCDK)QkT<^0W`P!yPNN&=xT63b4E0!1d7hh9ly?#5o3#0NWVB!&O#M6Ku)L0fzM z(~saL{5mK};lgn)&bv(H4v+N<=Ij(DyCps|@@?~Jg97jF+KepZ`!jfNA5p}<^l3z` zA$k0ymKS1Y7igSE;|ntrkEO5BPKfX8t>L*)rlia6@aZa_j{+`F-rMlNK%r5olU~U8 z90R3m9^dSRPeOs?)TzHNT?w1iy>POvyzB!9D8emT1)sOs*G16bjX}qdtlWmug}t*L zJj<^U#|&`mJL?*JWexqZU^?;16HSRLW7Q``o{U_2)p-y?o$b&HuzOI3jv?1i1bTIP zW7(ceB~E>e`)7AYkAD0M6W=Fo2c1Rs-K0=@=8URtvMKAzCftE>77C%d`SGWIvA-b7 zMY$;+*n@G#PVhm2S>s^g%6eIJg{;TaAVVldna@68Rf~%R2>5<^jCWO`mPMlsovoZG zhye&*{MjS)0uiz!b?6HD$ywyi7RvOZK{D^bw({~ZgN{crHM8hE5s>vh9cL&R*G+wT z=(f>kV-NO*nBY=a%uDR;(uJGE`?L1H0guZapM+}n$VV>gW^>+XdCa*}{9>|WrRe#A zya)^|?-d+w_{PlA*6qR3jWFdqR9+`3r&gQ#k7uayK_@>%B42r0klg*vV-8bABbCrc zZ=6OltMJpGZl9|w%qf=-^Qbbg^88{nRaDKBX%DxOXi{Qr26()?-mZpvpX4{2f4U&3 z5*eGLwp#FcZ*r5YTsYg6s`eepb1IT5!cF!tq*-&gs@JCt_f*Ox_{=abgAC1sSX@O4 zVyexf&3?a%0ldsym3GhNFyw~u-bj8ZFey#DbQ=+5f@tilbQK9KQpBJjrKvC9yk(Xa zpH6%`eq!tI?Wd|)&quz5n-c1mJ|9(01M3)^=~BPxs`A$AJY($Z)~}4(eH*&q%iu10 zz$Q$#_l`&uP8_jtZeZVg9C?uXJ}%bl$j{lY@`cedW~yO2t6>A}#WBmKkU+KLFw45H zoAfhJ3=p|Bhsi5=mdc^7^+_ZGKDyOsC-Eu4j~n~vxRTvoEoX;8`yh5&b3D7i>T@=I z_ohq+b-g1eWSTK0xm>h3;3(%Grur!Fh`{ia2*nlXF7gKg4&0xh6Y69AvMoHMFmyO)*2Ti`2IL zN^c?_`qiX2(7v29cb0pwpUI~`9sy^bi2UD@_`k!utPH><{cXk^a!A@d&6)Iu60$+J zUWEW0&&B^X+W!qj&UR~tUw7Q34?UkClgDPhg|9Orb`U^9wehLS)ec!XuRp%;rhcNm zN0Q-BIU(-i+34GMI)_=<^k(M9XiwD2^8=8MW_u1^oxmQeuda#69#lp#1HHA20O>D) zMij3yq;_e!{*O3E+S~^BxnF*=C^+VtH*#|L-syn7z75&T7H&h|Be1+GMU0B3_G~bd zElz4efOzhto>(>R`UkDUKZ!d#EhVk)+L8AKV9v+tj?(et3w!L zq?x`la!QS*`-S#o<^nG?_2DRU9>I11v?ErQ$M*&1wNYbu$QNF0MY2l1kfB6L+ zd1RQ~eo{K*67A{RSZevl#*%bNNxx%HsDVOE0MNUlpCjd)Hwa((oU!fMX(JgwwvY=8 z_M!U|pGj^DrPX?CAo5Sp^&#N*Bd`=6Nj jFVy@$02SF8dmQ+~p9K5QJy+Qu!-4b0=0;@(&XNBDh^sKY literal 0 HcmV?d00001 From da9d0e56334cf3f23561b81464df2b027efde572 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 9 Oct 2026 00:10:23 -0400 Subject: [PATCH 026/104] feat(mappers): add mapper 230 (reset-switched 22-in-1 multicart) Mapper 230 is the 22-in-1 whose console RESET button switches between two PRG chips. Written from the vendored NESdev page INES_Mapper_230, which is Disch's notes; no reference-emulator source was read. The board (m230_reset_multicart.rs): chip 0 is the image's first 128 KiB (Contra), chip 1 the next 512 KiB (the multicart). Contra mode decodes [.... .PPP]: 16 KiB page P at $8000, chip-0 page 7 fixed at $C000, mirroring always vertical. Multicart mode decodes [.MOP PPPP]: O = 0 one 32 KiB window at P >> 1, O = 1 page P in both halves, M = 0 horizontal, 1 vertical. CHR is 8 KiB of CHR-RAM. The console RESET reaches the board through Mapper::reset, which toggles the chip. The notes leave three things open, chosen and documented in the module: the power-on mode (Contra, the first chip in the image), the register across a toggle ("other stuff relating to the mode" -- cleared to 0), and the O polarity (read from the PRG-setup table's Mode 0 / Mode 1 rows). The dump agrees with all three: Contra's title at power-on, the menu after RESET, and a launched game that plays. Harness: InputScript::Held gains a resets list (frame indices at which the console RESET is pressed); the 207 and 252 scripts pass an empty list and their snapshots are byte-identical. Verification - Red first: four parse-driven tests failed with UnsupportedMapper(230) before the dispatch arm; 5 pass (Contra decode and fixed page, reset toggle both ways with the register cleared, M/O/P decode, CHR-RAM and state round trip, refused geometries). - 22-in-1 (Reset Based) [p1]: Contra's title at f600 (power-on); RESET at f700 brings up the "22 GAMES" menu, which scrolls its list and moves its cursor on DOWN; A launches entry 5, Sky Destroyer, and START puts it in flight by f3500. Both are byte-identity snapshots in external_extended; external_coverage blessed (its default capture stays in Contra). Two D23 rows and screenshots committed. - Mutation: a reset that only clears the register (no toggle) failed the reset unit test (left 0, right 8) and moved the f3500 frame; inverting O failed the M/O/P test (left 13, right 12) and moved f3500; fixing page 6 instead of 7 in Contra mode failed the power-on test (left 6, right 7) and moved the f600 frame (a9dfd293abb1eee0 -> 89ee4c476c97a325). Tier: Curated under this release's D23 rule (exact decode stated, a booting dump with a complete record). The page is Disch's notes, which the v2.9.6 rule would have made BestEffort; flagged for the integrator. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 8 + .../src/m230_reset_multicart.rs | 325 ++++++++++++++++++ crates/rustynes-mappers/src/mapper.rs | 5 +- crates/rustynes-mappers/src/tier.rs | 6 +- .../rustynes-test-harness/tests/common/mod.rs | 9 +- .../tests/external_extended.rs | 40 +++ ...2in1_Reset_22_in_1_Reset_Based_p1_zip.snap | 13 + ...ended_m230_22_in_1_contra_at_power_on.snap | 12 + ...230_22_in_1_sky_destroyer_after_reset.snap | 12 + docs/compatibility.md | 2 + docs/mappers.md | 1 + ...Reset Based) [p1] - Contra at power-on.png | Bin 0 -> 7063 bytes ...ased) [p1] - Sky Destroyer after reset.png | Bin 0 -> 3603 bytes 13 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 crates/rustynes-mappers/src/m230_reset_multicart.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_230_22in1_Reset_22_in_1_Reset_Based_p1_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_contra_at_power_on.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_sky_destroyer_after_reset.snap create mode 100644 screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Contra at power-on.png create mode 100644 screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Sky Destroyer after reset.png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 967572bc..30c1abbc 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -127,6 +127,7 @@ mod m184_sunsoft1; mod m185_cnrom185; mod m210_namco175; mod m228_action52; +mod m230_reset_multicart; mod m232_camerica_bf9096; mod m240_cne_multicart; mod m241_bxrom241; @@ -239,6 +240,7 @@ pub use m184_sunsoft1::Sunsoft1; pub use m185_cnrom185::CnRom185; pub use m210_namco175::{Namco175, Namco175Board}; pub use m228_action52::Action52M228; +pub use m230_reset_multicart::ResetMulticart230; pub use m232_camerica_bf9096::Camerica232; pub use m240_cne_multicart::Cne240; pub use m241_bxrom241::Bxrom241; @@ -1604,6 +1606,12 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { Waixing252::new(prg_rom, chr_rom, h.mirroring) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // 230: the reset-switched 22-in-1 (Contra + multicart); the console + // RESET toggles the chip (`m230_reset_multicart.rs`). + 230 => Box::new( + ResetMulticart230::new(prg_rom, &chr_rom) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/m230_reset_multicart.rs b/crates/rustynes-mappers/src/m230_reset_multicart.rs new file mode 100644 index 00000000..363b64cf --- /dev/null +++ b/crates/rustynes-mappers/src/m230_reset_multicart.rs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Mapper 230: the reset-switched *22-in-1* multicart (v3.2.0). +//! +//! Written from the vendored `NESdev` page `INES_Mapper_230`, which carries +//! Disch's notes; no reference-emulator source was read. The cartridge holds +//! two PRG chips back to back in the image: chip 0, 128 KiB, is *Contra*; +//! chip 1, 512 KiB, is the multicart. "Performing a Soft Reset switches +//! between them" -- the console's RESET button is the mode switch, delivered +//! to the board through [`Mapper::reset`]. +//! +//! ```text +//! Contra mode $8000-$FFFF: [.... .PPP] 16 KiB P at $8000, chip-0 page 7 +//! fixed at $C000; mirroring vertical +//! Multicart mode $8000-$FFFF: [.MOP PPPP] M: mirroring (0 = H, 1 = V) +//! O: PRG mode (0 = 32 KiB at +//! P >> 1, 1 = 16 KiB P twice) +//! P: 16 KiB page in chip 1 +//! ``` +//! +//! CHR is 8 KiB of CHR-RAM. No IRQ. +//! +//! **What the notes leave open, and the choices made:** +//! +//! - *Power-on mode.* The notes say only that a reset toggles it. This board +//! powers on in Contra mode, chip 0 being the first chip in the image; a +//! power cycle rebuilds the mapper, so it starts there again. +//! - *The register across a toggle.* "A soft reset changes which chip is used +//! as well as other stuff relating to the mode", with no detail, so a reset +//! clears the register to 0 in the new mode. +//! - *Mirroring field polarity in Contra mode.* "Mirroring is always Vert in +//! Contra Mode", so the register's bit 6 is ignored there. +//! - *O polarity.* The PRG-setup table lists "Multi Mode 0" as one 32 KiB +//! window and "Multi Mode 1" as two 16 KiB windows holding the same page. +//! - *Image layout.* "chip 0 = 128k PRG (offset 0x00010-0x2000F), chip 1 = +//! 512k PRG (offset 0x20010-0xA000F)": the first 128 KiB of PRG, then the +//! next 512 KiB. Other image sizes wrap each chip's page into its own region. + +#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)] + +use crate::cartridge::Mirroring; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name}; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const PRG_BANK_16K: usize = 0x4000; +const CHR_RAM_8K: usize = 0x2000; +/// Chip 0 (*Contra*) is the first 128 KiB of the image. +const CHIP0_LEN: usize = 128 * 1024; +const SAVE_STATE_VERSION: u8 = 1; + +/// Mapper 230 (22-in-1, reset-switched). See the module docs. +pub struct ResetMulticart230 { + prg_rom: Box<[u8]>, + chr_ram: Box<[u8]>, + vram: Box<[u8]>, + /// `true` in multicart mode (chip 1), `false` in Contra mode (chip 0). + multicart: bool, + /// The last `$8000-$FFFF` write. + reg: u8, +} + +impl ResetMulticart230 { + /// Construct the board in Contra mode. + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when PRG is not larger than the 128 KiB Contra + /// chip and a multiple of 16 KiB, or CHR-ROM is present (the board has + /// CHR-RAM only). + pub fn new(prg_rom: Box<[u8]>, chr_rom: &[u8]) -> Result { + if prg_rom.len() <= CHIP0_LEN || !prg_rom.len().is_multiple_of(PRG_BANK_16K) { + return Err(MapperError::Invalid(format!( + "mapper 230 PRG-ROM size {} is not 128 KiB of Contra plus a multicart chip", + prg_rom.len() + ))); + } + if !chr_rom.is_empty() { + return Err(MapperError::Invalid( + "mapper 230 has CHR-RAM only; this image carries CHR-ROM".into(), + )); + } + Ok(Self { + prg_rom, + chr_ram: vec![0u8; CHR_RAM_8K].into_boxed_slice(), + vram: vec![0u8; 0x800].into_boxed_slice(), + multicart: false, + reg: 0, + }) + } + + /// The image offset of the 16 KiB page `page` of the current chip. + fn page_base(&self, page: usize) -> usize { + if self.multicart { + let chip1_pages = (self.prg_rom.len() - CHIP0_LEN) / PRG_BANK_16K; + CHIP0_LEN + (page % chip1_pages) * PRG_BANK_16K + } else { + (page % (CHIP0_LEN / PRG_BANK_16K)) * PRG_BANK_16K + } + } + + /// The 16 KiB page mapped at `addr` (`$8000-$FFFF`). + fn page_at(&self, addr: u16) -> usize { + let upper = addr >= 0xC000; + if self.multicart { + let p = usize::from(self.reg & 0x1F); + if self.reg & 0x20 != 0 { + p // O = 1: the same 16 KiB page in both halves + } else { + (p & !1) | usize::from(upper) // O = 0: 32 KiB at P >> 1 + } + } else if upper { + 7 // Contra: chip-0 page 7 fixed at $C000 + } else { + usize::from(self.reg & 0x07) + } + } + + fn mirroring(&self) -> Mirroring { + if self.multicart && self.reg & 0x40 == 0 { + Mirroring::Horizontal + } else { + Mirroring::Vertical + } + } + + fn nt_offset(&self, addr: u16) -> usize { + let table = ((addr - 0x2000) / 0x400) & 0x03; + self.mirroring().physical_bank(table as u8) * 0x400 + (usize::from(addr) & 0x3FF) + } +} + +impl Mapper for ResetMulticart230 { + fn caps(&self) -> MapperCaps { + MapperCaps::NONE + } + + /// The console RESET toggles between Contra and the multicart and clears + /// the register (see the module docs). + fn reset(&mut self) { + self.multicart = !self.multicart; + self.reg = 0; + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + if addr >= 0x8000 { + self.prg_rom[self.page_base(self.page_at(addr)) + (usize::from(addr) & 0x3FFF)] + } else { + 0 + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + if addr >= 0x8000 { + self.reg = value; + } + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)] = value, + 0x2000..=0x3EFF => { + let off = self.nt_offset(addr); + self.vram[off] = value; + } + _ => {} + } + } + + fn nametable_address(&self, addr: u16) -> u16 { + u16::try_from(self.nt_offset(addr)).unwrap_or(0) + } + + fn current_mirroring(&self) -> Mirroring { + self.mirroring() + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = MapperDebugInfo { + mapper_id: 230, + name: "22-in-1 reset multicart (230)".into(), + mirroring: mirroring_name(self.mirroring()), + ..Default::default() + }; + info.prg_banks.push(( + "mode".into(), + if self.multicart { + "multicart" + } else { + "Contra" + } + .into(), + )); + info.prg_banks + .push(("$8000".into(), format!("{:#04x}", self.page_at(0x8000)))); + info.prg_banks + .push(("$C000".into(), format!("{:#04x}", self.page_at(0xC000)))); + info + } + + fn save_state(&self) -> Vec { + let mut out = Vec::with_capacity(3 + self.vram.len() + self.chr_ram.len()); + out.push(SAVE_STATE_VERSION); + out.push(u8::from(self.multicart)); + out.push(self.reg); + out.extend_from_slice(&self.vram); + out.extend_from_slice(&self.chr_ram); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + let expected = 3 + self.vram.len() + self.chr_ram.len(); + if data.len() != expected { + return Err(MapperError::WrongLength { + expected, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + self.multicart = data[1] != 0; + self.reg = data[2]; + let v = 3 + self.vram.len(); + self.vram.copy_from_slice(&data[3..v]); + self.chr_ram.copy_from_slice(&data[v..]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + /// The 22-in-1 geometry through `crate::parse`: 640 KiB PRG (Contra 128 + + /// multicart 512), CHR-RAM. Every 16 KiB page opens with its image index. + fn board() -> Box { + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = 40; + v[5] = 0; + v[6] = 0x61; // 230 = 0xE6: low nibble 6, vertical + v[7] = 0xE0; + for p in 0..40u8 { + let mut page = vec![0u8; PRG_BANK_16K]; + page[0] = p; + v.extend_from_slice(&page); + } + parse(&v) + .unwrap_or_else(|e| panic!("mapper 230 must parse: {e:?}")) + .1 + } + + #[test] + fn m230_powers_on_in_contra_mode_with_page_7_fixed() { + let mut m = board(); + assert_eq!(m.cpu_read(0x8000), 0); + assert_eq!(m.cpu_read(0xC000), 7, "chip-0 page 7 fixed"); + m.cpu_write(0x8000, 0xFD); // [.... .PPP]: only 3 bits + assert_eq!(m.cpu_read(0x8000), 5); + assert_eq!(m.cpu_read(0xC000), 7); + assert_eq!(m.current_mirroring(), Mirroring::Vertical, "always V"); + } + + #[test] + fn m230_reset_switches_to_the_multicart_chip_and_back() { + let mut m = board(); + m.cpu_write(0x8000, 0x03); + m.reset(); + // Register cleared, O = 0: 32 KiB at chip-1 pages 0/1 = image 8/9. + assert_eq!(m.cpu_read(0x8000), 8); + assert_eq!(m.cpu_read(0xC000), 9); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal, "M = 0"); + m.reset(); + assert_eq!(m.cpu_read(0x8000), 0, "back to Contra, register cleared"); + assert_eq!(m.cpu_read(0xC000), 7); + } + + #[test] + fn m230_multicart_register_decodes_m_o_p() { + let mut m = board(); + m.reset(); + m.cpu_write(0x8000, 0x05); // O = 0, P = 5: 32 KiB pages 4/5 + assert_eq!(m.cpu_read(0x8000), 8 + 4); + assert_eq!(m.cpu_read(0xC000), 8 + 5); + m.cpu_write(0xFFFF, 0x20 | 0x05); // O = 1: page 5 twice + assert_eq!(m.cpu_read(0x8000), 8 + 5); + assert_eq!(m.cpu_read(0xC000), 8 + 5); + m.cpu_write(0x8000, 0x40 | 0x1F); // M = 1, P = 31: last chip-1 pair + assert_eq!(m.current_mirroring(), Mirroring::Vertical); + assert_eq!(m.cpu_read(0x8000), 8 + 30); + assert_eq!(m.cpu_read(0xC000), 8 + 31); + } + + #[test] + fn m230_chr_ram_and_state_round_trip() { + let mut a = board(); + a.reset(); + a.cpu_write(0x8000, 0x26); + a.ppu_write(0x0123, 0x77); + a.ppu_write(0x2400, 0x11); + let blob = a.save_state(); + let mut b = board(); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.cpu_read(0x8000), a.cpu_read(0x8000)); + assert_eq!(b.ppu_read(0x0123), 0x77); + assert_eq!(b.current_mirroring(), Mirroring::Horizontal); + } + + #[test] + fn m230_rejects_chr_rom_and_a_contra_only_image() { + assert!(ResetMulticart230::new(vec![0; 0x20000].into(), &[]).is_err()); + assert!(ResetMulticart230::new(vec![0; 0xA0000].into(), &[0; 0x2000]).is_err()); + } +} diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index e71c6c48..44a800a7 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -459,8 +459,9 @@ pub trait Mapper: Send { /// on a console. The boards that clear something are documented per page: /// mapper 37's outer latch (`INES_Mapper_037.md`), mapper 45's outer /// registers, NES-EVENT's PRG lock (mapper 105), the 76-in-1 BMC's two - /// registers (mapper 226, since v2.9.8), and Action 52's register - /// (mapper 228). Added in v2.9.6; a power cycle rebuilds the mapper + /// registers (mapper 226, since v2.9.8), Action 52's register + /// (mapper 228), and the 22-in-1's chip toggle (mapper 230, v3.2.0, where + /// RESET is the mode switch). Added in v2.9.6; a power cycle rebuilds the mapper /// instead and never calls this. fn reset(&mut self) {} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 4c772e70..77d20068 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -142,7 +142,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // `docs/compatibility.md`). A separate arm so each release's batch // reads as one list; the bodies match on purpose. #[allow(clippy::match_same_arms)] - 158 | 172 | 173 | 207 | 252 => Some(MapperTier::Curated), + 158 | 172 | 173 | 207 | 230 | 252 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -250,8 +250,8 @@ mod tests { 178, 180, 185, 189, 193, 200, 201, 202, 203, 204, 205, 209, 211, 212, 213, 214, 218, 221, 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". - 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, 207, - 252, // v3.2.0 + 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, 207, 252, + 230, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index a2de06d4..69da28ee 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -344,10 +344,13 @@ pub mod external { /// menus ignore a one-frame tap (*Fudou Myouou Den*'s START / /// CONTINUE menu reads the button across several frames) or need a /// button other than START (*San Guo Zhi*'s questions take A), which - /// no other script can reach. + /// no other script can reach. The console RESET button is pressed at + /// the start of each frame index in `resets` (mapper 230's reset- + /// switched multicart). Held { presses: &'static [(u32, Buttons)], hold: u32, + resets: &'static [u32], frames: u32, }, } @@ -974,9 +977,13 @@ pub mod external { InputScript::Held { presses, hold, + resets, frames, } => { for f in 0..frames { + if resets.contains(&f) { + nes.reset(); + } let held = presses .iter() .filter(|&&(p, _)| f >= p && f < p + hold) diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index ca3c4ddd..e2478521 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1466,6 +1466,7 @@ fn extended_m158_alien_syndrome() { const FUDOU_IN_PLAY: InputScript = InputScript::Held { presses: &[(1900, Buttons::START), (2100, Buttons::START)], hold: 3, + resets: &[], frames: 3000, }; @@ -1497,6 +1498,7 @@ const SAN_GUO_ZHI_QUESTION: InputScript = InputScript::Held { (3600, Buttons::A), ], hold: 3, + resets: &[], frames: 3800, }; @@ -1508,3 +1510,41 @@ fn extended_m252_san_guo_zhi() { "extended_m252_san_guo_zhi", ); } + +/// *22-in-1 (Reset Based)* (mapper 230): it powers on in *Contra*; RESET at +/// f700 switches to the multicart chip and its menu, two held DOWNs move the +/// cursor to entry 5, *Sky Destroyer*, A launches it and START begins a game, +/// in flight by f3500. One run covers both chips, the reset toggle and the +/// multicart register. +const RESET_22_IN_1_SKY_DESTROYER: InputScript = InputScript::Held { + presses: &[ + (2300, Buttons::DOWN), + (2400, Buttons::DOWN), + (2600, Buttons::A), + (2900, Buttons::START), + ], + hold: 10, + resets: &[700], + frames: 3500, +}; + +/// The same board's power-on state, *Contra*'s title, before any reset. +const RESET_22_IN_1_CONTRA: InputScript = InputScript::IdleOnly { frames: 600 }; + +#[test] +fn extended_m230_22_in_1_contra_at_power_on() { + check( + "mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip", + RESET_22_IN_1_CONTRA, + "extended_m230_22_in_1_contra_at_power_on", + ); +} + +#[test] +fn extended_m230_22_in_1_sky_destroyer_after_reset() { + check( + "mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip", + RESET_22_IN_1_SKY_DESTROYER, + "extended_m230_22_in_1_sky_destroyer_after_reset", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_230_22in1_Reset_22_in_1_Reset_Based_p1_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_230_22in1_Reset_22_in_1_Reset_Based_p1_zip.snap new file mode 100644 index 00000000..80c9cd87 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_230_22in1_Reset_22_in_1_Reset_Based_p1_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip +rom_sha256=15563b6342a5e9aea36c80bac55f12b994fba946b1a015d74415d7733c8ec03c +frames=1141 +fb_bytes=245760 +cycles=33949775 +audio_samples=836505 +audio_fnv1a64=8cce4225d79e1359 +checkpoint f900 fb_fnv1a64=b2c81db658aa84b2 +checkpoint f1100 fb_fnv1a64=b2c81db658aa84b2 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_contra_at_power_on.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_contra_at_power_on.snap new file mode 100644 index 00000000..06caebab --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_contra_at_power_on.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip +rom_sha256=15563b6342a5e9aea36c80bac55f12b994fba946b1a015d74415d7733c8ec03c +frames=600 +fb_bytes=245760 +cycles=17838522 +audio_samples=439524 +audio_fnv1a64=3e997a3bd360d9b7 +checkpoint f600 fb_fnv1a64=a9dfd293abb1eee0 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_sky_destroyer_after_reset.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_sky_destroyer_after_reset.snap new file mode 100644 index 00000000..18e8e447 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m230_22_in_1_sky_destroyer_after_reset.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip +rom_sha256=15563b6342a5e9aea36c80bac55f12b994fba946b1a015d74415d7733c8ec03c +frames=3500 +fb_bytes=245760 +cycles=104201980 +audio_samples=2567502 +audio_fnv1a64=2ff54bb2106183e2 +checkpoint f3500 fb_fnv1a64=702db97133b15934 diff --git a/docs/compatibility.md b/docs/compatibility.md index 579d96ab..bb654c91 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -378,3 +378,5 @@ in `crates/rustynes-test-harness/tests/external_extended.rs`. | 158 | Alien Syndrome (USA) (Unl) | `d676ef6f7aa3b042ae5a0c95a7f07fabb4c3a5fccb3767ae1950713e51189a47` | 2400 (attract demo, in play) | `screenshots/external/mapper-158-Tengen800037/Alien Syndrome (USA) (Unl).png` | | 207 | Fudou Myouou Den (Japan) (En) (1.0) (translation, 207 header) | `e33482a375923d692bb5ddd3be71dab63de6a786ec311e8dfc32417a19e48fb3` | 3000 (START held at 1900 and 2100; stage 1 in play) | `screenshots/external/mapper-207-TaitoX1-005-207/Fudou Myouou Den (Japan) (En) (1.0).png` | | 252 | San Guo Zhi (Ch) | `659837c9b4ffbc6089aa191e6fdc238f50c1c2116a04b993381a5022444cb652` | 3800 (START held at 1500-3300, A at 3600; first personality question on the map) | `screenshots/external/mapper-252-WaixingVRC4/San Guo Zhi (Ch).png` | +| 230 | 22-in-1 (Reset Based) [p1] (Contra at power-on) | `cf3372f6f5b22b06c7382e48426033e3025310f8e4885fe001d3ed5d31a4da10` | 600 | `screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Contra at power-on.png` | +| 230 | 22-in-1 (Reset Based) [p1] (multicart after RESET) | `cf3372f6f5b22b06c7382e48426033e3025310f8e4885fe001d3ed5d31a4da10` | 3500 (RESET at 700, DOWN x2, A, START; Sky Destroyer in flight) | `screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Sky Destroyer after reset.png` | diff --git a/docs/mappers.md b/docs/mappers.md index 91a73343..5eaa6b2d 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,7 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | | 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`Held`: the menu ignores one-frame taps). | | 252 | — | Waixing VRC4 clone (`INES_Mapper_252`) | Curated | VRC4 (scanline / CPU) | `m252_waixing_vrc4.rs`: the project's `Vrc4` wired as mapper 23 submapper 2 (VRC4e: chip A0 = CPU A2, A1 = CPU A3) with its 8 KiB PRG-RAM and IRQ, plus 2 KiB of CHR-RAM: a 1 KiB slot whose bank register holds exactly 6 or 7 reads and writes RAM instead of CHR-ROM. The page names only the page numbers; a whole-value match is the plain reading, and *San Guo Zhi* rules out a 3-bit match (it would turn the portraits' ROM pages 14 and 23 into RAM). Booting it found the VRC4 IRQ mode-bit inversion described below. Mapper 253's older model in `waixing.rs` is not reused. | +| 230 | — | 22-in-1, reset-switched (`INES_Mapper_230`, Disch's notes) | Curated | — | `m230_reset_multicart.rs`. Chip 0 (first 128 KiB, *Contra*): `[.... .PPP]` 16 KiB at `$8000`, page 7 fixed at `$C000`, always vertical. Chip 1 (next 512 KiB): `[.MOP PPPP]`, O = 0 one 32 KiB window at P >> 1, O = 1 page P twice, M = 0 horizontal. The console RESET (`Mapper::reset`) toggles the chip and clears the register. The notes leave the power-on mode and the register across a toggle open: this board powers on in *Contra* and clears the register, and the dump behaves that way (title at power-on, menu after RESET). Curated on the brief's D23 rule although the page is Disch's notes only; the integrator may prefer the v2.9.6 rule that makes notes-only pages BestEffort. | **VRC4 IRQ mode bit, corrected (v3.2.0).** `m021_vrc4.rs` (mappers 21, 23, 25) read bit 2 of the IRQ control register the wrong way round: `VRC_IRQ` diff --git a/screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Contra at power-on.png b/screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Contra at power-on.png new file mode 100644 index 0000000000000000000000000000000000000000..2f3c6989282086567a35918f1eeebbe5579ab389 GIT binary patch literal 7063 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Sky Destroyer after reset.png b/screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Sky Destroyer after reset.png new file mode 100644 index 0000000000000000000000000000000000000000..f12adf12ee48d1acd6471112c83562636d5cc9fb GIT binary patch literal 3603 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 IW8v}OKRTSa6951J literal 0 HcmV?d00001 From 571b67a9dc2ebddba94fe2da64c02dd85b437d91 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Fri, 9 Oct 2026 00:31:43 -0400 Subject: [PATCH 027/104] feat(mappers): add mapper 235 (Golden Game address-latch multicart) Mapper 235 is the "Golden Game" 150-in-1 / 260-in-1 multicart. Written from the vendored NESdev page INES_Mapper_235 (The Mad Dumper's notes plus the page's description of the board's four TTL chips); no reference-emulator source was read. The board (m235_golden_game.rs) latches the write address, not the data: 1xMP RNBB xxxA AAAA. A is the 32 KiB page in the 1 MiB ROM socket B; R = 0 maps the whole 32 KiB, R = 1 maps half P of it at both $8000 and $C000 (the 74'153 picks PRG A14 from CPU A14 or P); M picks CIRAM A10 from PPU A11 (horizontal) or A10 (vertical) and N, that mux's enable, forces page A. "All eleven bits of the register seem to be cleared on reset", so Mapper::reset clears it, as power-on does. CHR is 8 KiB of CHR-RAM. Sockets: the board has four 1 MiB sockets and the page's cartridge fills two, with "00b = ROM 0, 10b = ROM 1". An image stores the filled chips in order, so a 4 MiB image is all four sockets, a 2 MiB image sockets 0 and 2, and 1 MiB or less socket 0; an empty socket reads open bus (cpu_read_unmapped). The dump: the GoodNES 260-in-1 [p1][!] holds 4 MiB of PRG with iNES byte 4 = 0, a size iNES cannot express (the INES page gives no "0 = 256" convention), so parse reads no PRG and the board refuses it. It is staged locally with a NES 2.0 header declaring 4 MiB (byte 7 bit 3, byte 9 PRG MSB 1); the body is unchanged, so the D23 hash is the dump's own. The [b1] image's header claims 2 MiB over the same 4 MiB file and was not used. Verification - Red first: five parse-driven tests failed with UnsupportedMapper(235) before the dispatch arm; 6 pass (32/16 KiB modes and A14 don't-care, sockets on 4/2/1 MiB images with open bus, M and N, reset, state round trip, refused geometries). - 260-in-1 boots to its menu (two columns of 40 entries over a scrolling forest), the cursor moves on DOWN/RIGHT, A launches the entry and START begins it: entry 21, "MR. MARIO I", is in phase 1 at f1600 (the snapshot frame). Entries 2 (a Tetris) and 24 (Excite Bike's track select) also launched in probing. external_coverage blessed; D23 row and screenshot. - Mutation: inverting R failed the 32/16 KiB test and the reset test and moved the f1600 frame (5fd1cd83d3b0b0dd -> 160106312b4d2325); ignoring the socket bits failed the socket test (left 2, right 194) and moved f1600 (-> 5b5dd8c5ebb12ea8); inverting M failed the mirroring tests but not the boot frame (Mario Bros does not scroll). Tier: Curated. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/lib.rs | 8 + .../rustynes-mappers/src/m235_golden_game.rs | 369 ++++++++++++++++++ crates/rustynes-mappers/src/mapper.rs | 5 +- crates/rustynes-mappers/src/tier.rs | 4 +- .../tests/external_extended.rs | 26 ++ ...Game_260_in_1_p1_NES_2_0_header_4_MiB.snap | 13 + ...ed__extended_m235_260_in_1_mario_bros.snap | 12 + docs/compatibility.md | 1 + docs/mappers.md | 1 + .../260-in-1 [p1][!] - Mr. Mario I.png | Bin 0 -> 3129 bytes 10 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 crates/rustynes-mappers/src/m235_golden_game.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_235_GoldenGame_260_in_1_p1_NES_2_0_header_4_MiB.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m235_260_in_1_mario_bros.snap create mode 100644 screenshots/external/mapper-235-GoldenGame/260-in-1 [p1][!] - Mr. Mario I.png diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 30c1abbc..5d727754 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -129,6 +129,7 @@ mod m210_namco175; mod m228_action52; mod m230_reset_multicart; mod m232_camerica_bf9096; +mod m235_golden_game; mod m240_cne_multicart; mod m241_bxrom241; mod m244_cne_decathlon; @@ -242,6 +243,7 @@ pub use m210_namco175::{Namco175, Namco175Board}; pub use m228_action52::Action52M228; pub use m230_reset_multicart::ResetMulticart230; pub use m232_camerica_bf9096::Camerica232; +pub use m235_golden_game::GoldenGame235; pub use m240_cne_multicart::Cne240; pub use m241_bxrom241::Bxrom241; pub use m244_cne_decathlon::Decathlon244; @@ -1612,6 +1614,12 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { ResetMulticart230::new(prg_rom, &chr_rom) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // 235: the "Golden Game" address-latch multicart with four 1 MiB + // sockets (`m235_golden_game.rs`). + 235 => Box::new( + GoldenGame235::new(prg_rom, &chr_rom) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ), other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/m235_golden_game.rs b/crates/rustynes-mappers/src/m235_golden_game.rs new file mode 100644 index 00000000..5efdf7fd --- /dev/null +++ b/crates/rustynes-mappers/src/m235_golden_game.rs @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Mapper 235: the "Golden Game" 150-in-1 / 260-in-1 address-latch multicart +//! (v3.2.0). +//! +//! Written from the vendored `NESdev` page `INES_Mapper_235` (The Mad +//! Dumper's notes plus the page's own description of the four TTL chips); no +//! reference-emulator source was read. Like other multicarts, "the address +//! written to is used instead of the actual data written": +//! +//! ```text +//! A15 A0 +//! 1xMP RNBB xxxA AAAA +//! || |||| | +++++- 32 KiB page within the selected 1 MiB ROM +//! || ||++----------- B: ROM socket (A8 = bit 0, A9 = bit 1) +//! || |+------------- N: 1 = one-screen (CIRAM A10 held low: 1scA) +//! || +-------------- R: 0 = 32 KiB at $8000, 1 = one 16 KiB half twice +//! |+---------------- P: which 16 KiB half when R = 1 +//! +----------------- M: mirroring, 0 = horizontal, 1 = vertical +//! ``` +//! +//! The page's chip description fixes the details: a 74'153 mux picks PRG A14 +//! from CPU A14 (R = 0) or P (R = 1), and a second mux picks CIRAM A10 from +//! PPU A11 (M = 0) or PPU A10 (M = 1) with N as its enable, so N forces page +//! A. "All eleven bits of the register seem to be cleared on reset", so the +//! console RESET ([`Mapper::reset`]) clears it, as does power-on. CHR is 8 KiB +//! of CHR-RAM. No IRQ. +//! +//! **Sockets and the image.** The board has four 1 MiB ROM sockets; the +//! page's cartridge has two filled and says the valid selections are "ROM 0 +//! and ROM 2 (i.e.: 00b = ROM 0, 10b = ROM 1)": socket 2 holds the second +//! chip. An image stores the filled chips in order, so this model maps: +//! +//! - a 4 MiB image: all four sockets, B indexing the image's 1 MiB quarters; +//! - a 2 MiB image: sockets 0 and 2 (the page's cartridge), sockets 1 and 3 +//! empty; +//! - 1 MiB or less: socket 0 only, the image wrapped within it. +//! +//! An empty socket drives nothing, so the CPU reads open bus there. + +#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)] + +use crate::cartridge::Mirroring; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name}; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const PRG_BANK_16K: usize = 0x4000; +const PRG_BANK_32K: usize = 0x8000; +const SOCKET: usize = 1024 * 1024; +const CHR_RAM_8K: usize = 0x2000; +const SAVE_STATE_VERSION: u8 = 1; + +/// Mapper 235 ("Golden Game" multicart). See the module docs. +pub struct GoldenGame235 { + prg_rom: Box<[u8]>, + chr_ram: Box<[u8]>, + vram: Box<[u8]>, + /// The latched write address (A0-A13; A14 is don't-care, A15 always 1). + reg: u16, +} + +impl GoldenGame235 { + /// Construct the board with the register cleared. + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 32 KiB + /// or CHR-ROM is present (the board has CHR-RAM only). + pub fn new(prg_rom: Box<[u8]>, chr_rom: &[u8]) -> Result { + if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) { + return Err(MapperError::Invalid(format!( + "mapper 235 PRG-ROM size {} is not a non-zero multiple of 32 KiB", + prg_rom.len() + ))); + } + if !chr_rom.is_empty() { + return Err(MapperError::Invalid( + "mapper 235 has CHR-RAM only; this image carries CHR-ROM".into(), + )); + } + Ok(Self { + prg_rom, + chr_ram: vec![0u8; CHR_RAM_8K].into_boxed_slice(), + vram: vec![0u8; 0x800].into_boxed_slice(), + reg: 0, + }) + } + + /// The image's 1 MiB chip behind socket `b`, or `None` for an empty one. + fn chip(&self, b: usize) -> Option { + match self.prg_rom.len() { + l if l >= 4 * SOCKET => Some(b), + l if l >= 2 * SOCKET => b.is_multiple_of(2).then_some(b / 2), + _ => (b == 0).then_some(0), + } + } + + /// The image offset for CPU `addr`, or `None` when the socket is empty. + fn prg_offset(&self, addr: u16) -> Option { + let b = usize::from((self.reg >> 8) & 0x03); + let chip = self.chip(b)?; + let page = usize::from(self.reg & 0x1F); + let a14 = if self.reg & 0x0800 != 0 { + usize::from(self.reg & 0x1000 != 0) // R = 1: P + } else { + usize::from(addr & 0x4000 != 0) // R = 0: CPU A14 + }; + let off = chip * SOCKET + page * PRG_BANK_32K + a14 * PRG_BANK_16K; + Some(off % self.prg_rom.len() + (usize::from(addr) & 0x3FFF)) + } + + fn mirroring(&self) -> Mirroring { + if self.reg & 0x0400 != 0 { + Mirroring::SingleScreenA + } else if self.reg & 0x2000 != 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal + } + } + + fn nt_offset(&self, addr: u16) -> usize { + let table = ((addr - 0x2000) / 0x400) & 0x03; + self.mirroring().physical_bank(table as u8) * 0x400 + (usize::from(addr) & 0x3FF) + } +} + +impl Mapper for GoldenGame235 { + fn caps(&self) -> MapperCaps { + MapperCaps::NONE + } + + /// "All eleven bits of the register seem to be cleared on reset." + fn reset(&mut self) { + self.reg = 0; + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + match addr { + 0x4020..=0x7FFF => true, + 0x8000..=0xFFFF => self.prg_offset(addr).is_none(), + _ => false, + } + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + if addr >= 0x8000 { + self.prg_offset(addr).map_or(0, |o| self.prg_rom[o]) + } else { + 0 + } + } + + fn cpu_write(&mut self, addr: u16, _value: u8) { + if addr >= 0x8000 { + self.reg = addr & 0x3FFF; + } + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)] = value, + 0x2000..=0x3EFF => { + let off = self.nt_offset(addr); + self.vram[off] = value; + } + _ => {} + } + } + + fn nametable_address(&self, addr: u16) -> u16 { + u16::try_from(self.nt_offset(addr)).unwrap_or(0) + } + + fn current_mirroring(&self) -> Mirroring { + self.mirroring() + } + + fn debug_info(&self) -> MapperDebugInfo { + let mut info = MapperDebugInfo { + mapper_id: 235, + name: "Golden Game multicart (235)".into(), + mirroring: mirroring_name(self.mirroring()), + ..Default::default() + }; + info.prg_banks + .push(("socket".into(), format!("{}", (self.reg >> 8) & 0x03))); + info.prg_banks + .push(("page".into(), format!("{:#04x}", self.reg & 0x1F))); + info.prg_banks.push(( + "16K".into(), + if self.reg & 0x0800 != 0 { + if self.reg & 0x1000 != 0 { + "upper" + } else { + "lower" + } + } else { + "no (32 KiB)" + } + .into(), + )); + info + } + + fn save_state(&self) -> Vec { + let mut out = Vec::with_capacity(3 + self.vram.len() + self.chr_ram.len()); + out.push(SAVE_STATE_VERSION); + out.extend_from_slice(&self.reg.to_le_bytes()); + out.extend_from_slice(&self.vram); + out.extend_from_slice(&self.chr_ram); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + let expected = 3 + self.vram.len() + self.chr_ram.len(); + if data.len() != expected { + return Err(MapperError::WrongLength { + expected, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + self.reg = u16::from_le_bytes([data[1], data[2]]) & 0x3FFF; + let v = 3 + self.vram.len(); + self.vram.copy_from_slice(&data[3..v]); + self.chr_ram.copy_from_slice(&data[v..]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + /// A mapper 235 image through `crate::parse` with `mib` MiB of PRG (NES + /// 2.0, so 4 MiB is expressible); every 16 KiB page opens with its index + /// low byte and the next byte with its index high byte. + fn board(mib: usize) -> Box { + let pages = mib * 64; + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = (pages & 0xFF) as u8; + v[5] = 0; + v[6] = 0xB0; // 235 = 0xEB: low nibble 0xB, horizontal + v[7] = 0xE8; // high nibble 0xE, NES 2.0 + v[9] = (pages >> 8) as u8; // PRG size MSB + for p in 0..pages { + let mut page = vec![0u8; PRG_BANK_16K]; + page[0] = p as u8; + page[1] = (p >> 8) as u8; + v.extend_from_slice(&page); + } + parse(&v) + .unwrap_or_else(|e| panic!("mapper 235 must parse: {e:?}")) + .1 + } + + fn page_at(m: &mut dyn Mapper, addr: u16) -> usize { + usize::from(m.cpu_read(addr)) | (usize::from(m.cpu_read(addr + 1)) << 8) + } + + /// The register address for the named fields (the page's letters). + #[allow(clippy::fn_params_excessive_bools, clippy::many_single_char_names)] + fn reg(m: bool, p: bool, r: bool, n: bool, b: u16, a: u16) -> u16 { + 0x8000 + | (u16::from(m) << 13) + | (u16::from(p) << 12) + | (u16::from(r) << 11) + | (u16::from(n) << 10) + | (b << 8) + | a + } + + #[test] + fn m235_32k_and_16k_modes_from_the_address() { + let mut m = board(4); + assert_eq!(page_at(m.as_mut(), 0x8000), 0, "power-on: page 0"); + assert_eq!(page_at(m.as_mut(), 0xC000), 1); + m.cpu_write(reg(false, false, false, false, 0, 5), 0xFF); + assert_eq!(page_at(m.as_mut(), 0x8000), 10, "32 KiB page 5"); + assert_eq!(page_at(m.as_mut(), 0xC000), 11); + m.cpu_write(reg(false, true, true, false, 0, 5), 0); + assert_eq!(page_at(m.as_mut(), 0x8000), 11, "R = 1, P = 1: upper half"); + assert_eq!(page_at(m.as_mut(), 0xC000), 11, "twice"); + m.cpu_write(reg(false, false, true, false, 0, 5), 0); + assert_eq!(page_at(m.as_mut(), 0xC000), 10, "P = 0: lower half"); + // A14 of the write address is don't-care. + m.cpu_write(reg(false, false, false, false, 0, 3) | 0x4000, 0); + assert_eq!(page_at(m.as_mut(), 0x8000), 6); + } + + #[test] + fn m235_sockets_on_4_2_and_1_mib_images() { + let mut m = board(4); + m.cpu_write(reg(false, false, false, false, 3, 1), 0); + assert_eq!(page_at(m.as_mut(), 0x8000), 3 * 64 + 2, "4 MiB: socket 3"); + let mut m = board(2); + m.cpu_write(reg(false, false, false, false, 2, 1), 0); + assert_eq!( + page_at(m.as_mut(), 0x8000), + 64 + 2, + "2 MiB: socket 2 = chip 1" + ); + m.cpu_write(reg(false, false, false, false, 1, 1), 0); + assert!(m.cpu_read_unmapped(0x8000), "socket 1 empty: open bus"); + assert!(m.cpu_read_unmapped(0xFFFC)); + let mut m = board(1); + m.cpu_write(reg(false, false, false, false, 2, 0), 0); + assert!(m.cpu_read_unmapped(0x8000), "1 MiB: only socket 0"); + m.cpu_write(reg(false, false, false, false, 0, 0), 0); + assert!(!m.cpu_read_unmapped(0x8000)); + } + + #[test] + fn m235_mirroring_m_and_one_screen_n() { + let mut m = board(2); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal, "M = 0"); + m.cpu_write(reg(true, false, false, false, 0, 0), 0); + assert_eq!(m.current_mirroring(), Mirroring::Vertical, "M = 1"); + m.cpu_write(reg(true, false, false, true, 0, 0), 0); + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA, "N wins"); + m.cpu_write(reg(false, false, false, true, 0, 0), 0); + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA); + } + + #[test] + fn m235_reset_clears_the_register() { + let mut m = board(2); + m.cpu_write(reg(true, true, true, true, 2, 9), 0); + m.reset(); + assert_eq!(page_at(m.as_mut(), 0x8000), 0); + assert_eq!(page_at(m.as_mut(), 0xC000), 1); + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + } + + #[test] + fn m235_chr_ram_and_state_round_trip() { + let mut a = board(2); + a.cpu_write(reg(true, false, false, false, 2, 7), 0); + a.ppu_write(0x1FFF, 0x3C); + a.ppu_write(0x2C00, 0x5D); + let blob = a.save_state(); + let mut b = board(2); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(page_at(b.as_mut(), 0x8000), page_at(a.as_mut(), 0x8000)); + assert_eq!(b.ppu_read(0x1FFF), 0x3C); + assert_eq!(b.ppu_read(0x2C00), 0x5D); + } + + #[test] + fn m235_rejects_chr_rom_and_odd_prg() { + assert!(GoldenGame235::new(vec![0; 0x4000].into(), &[]).is_err()); + assert!(GoldenGame235::new(vec![0; 0x8000].into(), &[0; 0x2000]).is_err()); + } +} diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 44a800a7..0d191abd 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -460,8 +460,9 @@ pub trait Mapper: Send { /// mapper 37's outer latch (`INES_Mapper_037.md`), mapper 45's outer /// registers, NES-EVENT's PRG lock (mapper 105), the 76-in-1 BMC's two /// registers (mapper 226, since v2.9.8), Action 52's register - /// (mapper 228), and the 22-in-1's chip toggle (mapper 230, v3.2.0, where - /// RESET is the mode switch). Added in v2.9.6; a power cycle rebuilds the mapper + /// (mapper 228), the 22-in-1's chip toggle (mapper 230, v3.2.0, where + /// RESET is the mode switch), and the Golden Game multicart's register + /// (mapper 235, v3.2.0). Added in v2.9.6; a power cycle rebuilds the mapper /// instead and never calls this. fn reset(&mut self) {} diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 77d20068..5bbbb58e 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -142,7 +142,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // `docs/compatibility.md`). A separate arm so each release's batch // reads as one list; the bodies match on purpose. #[allow(clippy::match_same_arms)] - 158 | 172 | 173 | 207 | 230 | 252 => Some(MapperTier::Curated), + 158 | 172 | 173 | 207 | 230 | 235 | 252 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -251,7 +251,7 @@ mod tests { 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, 207, 252, - 230, // v3.2.0 + 230, 235, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index e2478521..3b86166d 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1548,3 +1548,29 @@ fn extended_m230_22_in_1_sky_destroyer_after_reset() { "extended_m230_22_in_1_sky_destroyer_after_reset", ); } + +/// *260-in-1* (mapper 235): RIGHT moves the menu cursor to entry 21, "MR. +/// MARIO I", A launches it and START begins a game; phase 1 is in play by +/// f1600. The staged image is the GoodNES `[!]` dump with its header +/// rewritten to NES 2.0: its iNES byte 4 is 0, which iNES cannot use for the +/// 4 MiB it holds (the D23 hash is of the bytes after the header, which are +/// unchanged). +const GOLDEN_GAME_MARIO: InputScript = InputScript::Held { + presses: &[ + (600, Buttons::RIGHT), + (800, Buttons::A), + (1100, Buttons::START), + ], + hold: 6, + resets: &[], + frames: 1600, +}; + +#[test] +fn extended_m235_260_in_1_mario_bros() { + check( + "mapper-235-GoldenGame/260-in-1 [p1][!] (NES 2.0 header, 4 MiB).nes", + GOLDEN_GAME_MARIO, + "extended_m235_260_in_1_mario_bros", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_235_GoldenGame_260_in_1_p1_NES_2_0_header_4_MiB.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_235_GoldenGame_260_in_1_p1_NES_2_0_header_4_MiB.snap new file mode 100644 index 00000000..96cd381a --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_235_GoldenGame_260_in_1_p1_NES_2_0_header_4_MiB.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-235-GoldenGame/260-in-1 [p1][!] (NES 2.0 header, 4 MiB).nes +rom_sha256=607dce48822afd9c2a86847b805db599d154a52a995f2d48d8fa02d88da2b5f7 +frames=1141 +fb_bytes=245760 +cycles=33949779 +audio_samples=836505 +audio_fnv1a64=c21a3e6743ef16ff +checkpoint f900 fb_fnv1a64=3caa3a9fe81d2972 +checkpoint f1100 fb_fnv1a64=ec893a5057580e0a diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m235_260_in_1_mario_bros.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m235_260_in_1_mario_bros.snap new file mode 100644 index 00000000..c4cbd8d4 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m235_260_in_1_mario_bros.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-235-GoldenGame/260-in-1 [p1][!] (NES 2.0 header, 4 MiB).nes +rom_sha256=607dce48822afd9c2a86847b805db599d154a52a995f2d48d8fa02d88da2b5f7 +frames=1600 +fb_bytes=245760 +cycles=47619040 +audio_samples=1173315 +audio_fnv1a64=a4e11fc0e0b9ca37 +checkpoint f1600 fb_fnv1a64=5fd1cd83d3b0b0dd diff --git a/docs/compatibility.md b/docs/compatibility.md index bb654c91..fe52da41 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -380,3 +380,4 @@ in `crates/rustynes-test-harness/tests/external_extended.rs`. | 252 | San Guo Zhi (Ch) | `659837c9b4ffbc6089aa191e6fdc238f50c1c2116a04b993381a5022444cb652` | 3800 (START held at 1500-3300, A at 3600; first personality question on the map) | `screenshots/external/mapper-252-WaixingVRC4/San Guo Zhi (Ch).png` | | 230 | 22-in-1 (Reset Based) [p1] (Contra at power-on) | `cf3372f6f5b22b06c7382e48426033e3025310f8e4885fe001d3ed5d31a4da10` | 600 | `screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Contra at power-on.png` | | 230 | 22-in-1 (Reset Based) [p1] (multicart after RESET) | `cf3372f6f5b22b06c7382e48426033e3025310f8e4885fe001d3ed5d31a4da10` | 3500 (RESET at 700, DOWN x2, A, START; Sky Destroyer in flight) | `screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Sky Destroyer after reset.png` | +| 235 | `260-in-1 [p1][!]` (staged with a NES 2.0 header; body unchanged) | `0c62f12da81c712a8a9d56e808ff8825d824169cfff05eee2a15c7456f3ceb91` | 1600 (RIGHT, A, START; Mr. Mario I phase 1) | `screenshots/external/mapper-235-GoldenGame/260-in-1 [p1][!] - Mr. Mario I.png` | diff --git a/docs/mappers.md b/docs/mappers.md index 5eaa6b2d..751c8db4 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,7 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | | 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`Held`: the menu ignores one-frame taps). | | 252 | — | Waixing VRC4 clone (`INES_Mapper_252`) | Curated | VRC4 (scanline / CPU) | `m252_waixing_vrc4.rs`: the project's `Vrc4` wired as mapper 23 submapper 2 (VRC4e: chip A0 = CPU A2, A1 = CPU A3) with its 8 KiB PRG-RAM and IRQ, plus 2 KiB of CHR-RAM: a 1 KiB slot whose bank register holds exactly 6 or 7 reads and writes RAM instead of CHR-ROM. The page names only the page numbers; a whole-value match is the plain reading, and *San Guo Zhi* rules out a 3-bit match (it would turn the portraits' ROM pages 14 and 23 into RAM). Booting it found the VRC4 IRQ mode-bit inversion described below. Mapper 253's older model in `waixing.rs` is not reused. | +| 235 | — | "Golden Game" 150-in-1 / 260-in-1 (`INES_Mapper_235`) | Curated | — | `m235_golden_game.rs`. Address latch `1xMP RNBB xxxA AAAA`: 32 KiB page A within the 1 MiB socket B; R = 1 maps half P of it twice; M picks CIRAM A10 from PPU A11 / A10 (H / V) and N forces page A (1scA), per the page's 74'153 description. RESET and power-on clear the register ("All eleven bits ... cleared on reset"). Sockets: a 4 MiB image fills all four; a 2 MiB image is sockets 0 and 2 ("00b = ROM 0, 10b = ROM 1"); 1 MiB or less is socket 0; an empty socket is open bus. The GoodNES `260-in-1 [p1][!]` header has iNES byte 4 = 0 for its 4 MiB, which iNES cannot express, so `parse` reads no PRG; it is staged with a NES 2.0 header (PRG MSB 1) and its body unchanged. | | 230 | — | 22-in-1, reset-switched (`INES_Mapper_230`, Disch's notes) | Curated | — | `m230_reset_multicart.rs`. Chip 0 (first 128 KiB, *Contra*): `[.... .PPP]` 16 KiB at `$8000`, page 7 fixed at `$C000`, always vertical. Chip 1 (next 512 KiB): `[.MOP PPPP]`, O = 0 one 32 KiB window at P >> 1, O = 1 page P twice, M = 0 horizontal. The console RESET (`Mapper::reset`) toggles the chip and clears the register. The notes leave the power-on mode and the register across a toggle open: this board powers on in *Contra* and clears the register, and the dump behaves that way (title at power-on, menu after RESET). Curated on the brief's D23 rule although the page is Disch's notes only; the integrator may prefer the v2.9.6 rule that makes notes-only pages BestEffort. | **VRC4 IRQ mode bit, corrected (v3.2.0).** `m021_vrc4.rs` (mappers 21, 23, diff --git a/screenshots/external/mapper-235-GoldenGame/260-in-1 [p1][!] - Mr. Mario I.png b/screenshots/external/mapper-235-GoldenGame/260-in-1 [p1][!] - Mr. Mario I.png new file mode 100644 index 0000000000000000000000000000000000000000..86e3dbf01c7a0f239910e24f85c22378e65786a8 GIT binary patch literal 3129 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 Date: Fri, 9 Oct 2026 01:51:34 -0400 Subject: [PATCH 028/104] feat(mappers): add mappers 6, 8 and 17 (FFE Magic Card / Super Magic Card) Mappers 6, 8 and 17 are not cartridge boards: they are games extracted from Front Fareast copier disks, which ran on the Super Magic Card RAM cartridge. Written from the vendored NESdev pages INES_Mapper_006, INES_Mapper_008, INES_Mapper_017 and Super_Magic_Card; no reference-emulator source was read. The model (ffe_super_magic_card.rs) is the card in Play mode: 512 KiB PRG DRAM, 256 KiB CHR-RAM, 32 KiB banked WRAM and 4 KiB scratch RAM, with the image's PRG and CHR loaded at the start of each. The iNES number only sets the initial registers, exactly as the pages list them: - 6: $4500 = $42, $42FF = submapper << 5 (iNES 1.0 = 1, UN1ROM+CHRSW) with H/V from the header, 2M/4M off; - 8: "a synonym of Mapper 6 submapper 4" (GNROM latch mode); - 17: $4500 = $47 (1 KiB CHR), $42FF = $20, 4M mode, $4504-$4507 = the last four 8 KiB banks. Modelled: all eight latch modes (UNROM, UN1ROM+CHRSW, UOROM, reverse UOROM, GNROM, CNROM-256/128, NROM-256) with their CHR write protection; the $42Fx write-enable (PRG DRAM writable, latch off); the 2M and 4M PRG modes; 1 KiB CHR banking; CHR-RAM nametables ($4518-$451B); MMC4 mode; the state registers at $4500/$4501; the card's 16-bit up-counter on M2 or unfiltered PA12 rises, IRQ on $FFFF -> $0000; and the FDS RAM adapter's disk IRQ that "a few games abuse" ($4025 bit 7, every 1792 master clocks, $4024 acknowledges). Trainers: parse now hands the iNES trainer to the card. Mapper 6 loads it at $7000 and a power-on reset runs JSR $7003 then the game's vector, through a six-byte overlay at $7200 that the board serves in answer to the first reset-vector fetch and drops once fetched; mapper 17 loads it at $7000/$5D00/$5E00/$5F00 (submapper) and jumps there. A soft reset uses the ordinary vector ("on a hard reset"). The mirroring-type encoding A0 << 1 | D4 is the only one that fits the pages' two initial values. GUI and pass-through modes, the parallel port and the floppy controller are not modelled. Left open by the pages and chosen, documented in the module: mapper 17's initial $4510-$4517 (0-7); the 4M registers "mirroring" the 2M ones when 4M mode is off (a $450x write then also loads the 2M register); the 2M registers not latching while the DRAM is write-enabled. external_coverage: the two Doraemon World 3 hack images in mapper-072-Jaleco72/, whose masked dirty header is mapper 8, leave the UNSUPPORTED table; they boot to their title and stage-select screens and carry ordinary snapshots now. Verification - Red first: the parse-driven tests failed with UnsupportedMapper(6/8/17) before the dispatch arm; 10 pass now (each latch mode, mapper 8, the mirroring types and write enable, 17's 4M start and 1 KiB CHR and 2M mode, CHR nametables, MMC4 latches, both IRQs, both trainer paths and the soft-reset exception, state round trip). - Booted into play or the first in-game screen, byte-identity snapshots in external_extended and D23 rows with screenshots: 6: Ganbare Goemon! (trainer), Cosmo Police Galivan [p1], Wai Wai World (trainer, first stage at f3800); 8: Mermaids of Atlantis (AVE) [T+Rus_Guyver] (in play); 17: Kaiketsu Yanchamaru 2 (in play), Juvei Quest (trainer, name entry), Dragon Ball Z III [p1] (story box). Also probed and working: Arkanoid II, Kyonshiizu 2, Moero!! Pro Tennis, Dragon Ball - Shen Long no Nazo [b1] (staged, coverage only). - Mutation (unit tests and the seven boot snapshots): UN1ROM PRG bits from D3-D0 instead of D5-D2: unit test (left 14, right 11) and all three mapper 6 boots; no mapper 6 trainer call: unit test (left 52, right 0) and Wai Wai World; 17's initial 4M banks one lower: unit test and all three mapper 17 boots; 1 KiB CHR mode ignored: two unit tests and all three mapper 17 boots; mirroring type decoded D4 << 1 | A0: two unit tests and four boots; card IRQ never firing: unit test plus Dragon Ball Z III and Juvei Quest; FDS adapter IRQ never firing: unit test only (no staged game uses it). Not verified / not staged - Contra (J) [hFFE] plays stage 1 with a black background under its header's horizontal mirroring and correctly under vertical; the game never writes $42Fx, so the header decides, and this dump's header bit looks wrong. Not staged. - Kaiketsu Yanchamaru (J) [hFFE] (no trainer) stops on a grey screen: its reset vector in every PRG page points at data, so it needs set-up the dump does not carry. Not staged. - Save states carry the whole card (about 804 KiB) because the PRG DRAM is writable; rewind and run-ahead pay for that on these three mappers. Tier: Curated for 6, 8 and 17. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../src/ffe_super_magic_card.rs | 1013 +++++++++++++++++ crates/rustynes-mappers/src/lib.rs | 27 + crates/rustynes-mappers/src/tier.rs | 4 +- .../tests/external_coverage.rs | 11 +- .../tests/external_extended.rs | 104 ++ ...rd_Cosmo_Police_Galivan_J_hFFE_p1_zip.snap | 13 + ...re_Goemon_Karakuri_Douchuu_J_hFFE_zip.snap | 13 + ...FE_MagicCard_Wai_Wai_World_J_hFFE_zip.snap | 13 + ..._Ball_Shen_Long_no_Nazo_J_hFFE_b1_zip.snap | 13 + ...the_Magic_Bubble_AVE_T_Rus_Guyver_zip.snap | 13 + ...II_Ressen_Jinzou_Ningen_J_hFFE_p1_zip.snap | 13 + ...SuperMagicCard_Juvei_Quest_J_hFFE_zip.snap | 13 + ...Yanchamaru_2_Karakuri_Land_J_hFFE_zip.snap | 13 + ...oraemon_World_3_by_Kiku_Doraemon_Hack.snap | 13 + ...mon_World_3_by_Kiku_Doraemon_Hack_zip.snap | 13 + ...d__extended_m006_cosmo_police_galivan.snap | 12 + ...xtended__extended_m006_ganbare_goemon.snap | 12 + ...extended__extended_m006_wai_wai_world.snap | 12 + ...d__extended_m008_mermaids_of_atlantis.snap | 12 + ...nded__extended_m017_dragon_ball_z_iii.snap | 12 + ...l_extended__extended_m017_juvei_quest.snap | 12 + ...__extended_m017_kaiketsu_yanchamaru_2.snap | 12 + docs/compatibility.md | 7 + docs/mappers.md | 3 + .../Cosmo Police Galivan (J) [hFFE][p1].png | Bin 0 -> 3280 bytes ... Goemon! - Karakuri Douchuu (J) [hFFE].png | Bin 0 -> 4984 bytes .../Wai Wai World (J) [hFFE].png | Bin 0 -> 4915 bytes ... the Magic Bubble (AVE) [T+Rus_Guyver].png | Bin 0 -> 9358 bytes ... - Ressen Jinzou Ningen (J) [hFFE][p1].png | Bin 0 -> 5985 bytes .../Juvei Quest (J) [hFFE].png | Bin 0 -> 3340 bytes ...anchamaru 2 - Karakuri Land (J) [hFFE].png | Bin 0 -> 2749 bytes 31 files changed, 1373 insertions(+), 10 deletions(-) create mode 100644 crates/rustynes-mappers/src/ffe_super_magic_card.rs create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Cosmo_Police_Galivan_J_hFFE_p1_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Ganbare_Goemon_Karakuri_Douchuu_J_hFFE_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Wai_Wai_World_J_hFFE_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Dragon_Ball_Shen_Long_no_Nazo_J_hFFE_b1_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Mermaids_of_Atlantis_The_Riddle_of_the_Magic_Bubble_AVE_T_Rus_Guyver_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Dragon_Ball_Z_III_Ressen_Jinzou_Ningen_J_hFFE_p1_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Juvei_Quest_J_hFFE_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Kaiketsu_Yanchamaru_2_Karakuri_Land_J_hFFE_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack_zip.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_cosmo_police_galivan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_ganbare_goemon.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_wai_wai_world.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m008_mermaids_of_atlantis.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_dragon_ball_z_iii.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_juvei_quest.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_kaiketsu_yanchamaru_2.snap create mode 100644 screenshots/external/mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].png create mode 100644 screenshots/external/mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].png create mode 100644 screenshots/external/mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].png create mode 100644 screenshots/external/mapper-008-FFE-GNROM/Mermaids of Atlantis - The Riddle of the Magic Bubble (AVE) [T+Rus_Guyver].png create mode 100644 screenshots/external/mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].png create mode 100644 screenshots/external/mapper-017-FFE-SuperMagicCard/Juvei Quest (J) [hFFE].png create mode 100644 screenshots/external/mapper-017-FFE-SuperMagicCard/Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE].png diff --git a/crates/rustynes-mappers/src/ffe_super_magic_card.rs b/crates/rustynes-mappers/src/ffe_super_magic_card.rs new file mode 100644 index 00000000..c4d9ce15 --- /dev/null +++ b/crates/rustynes-mappers/src/ffe_super_magic_card.rs @@ -0,0 +1,1013 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +//! Mappers 6, 8 and 17: games extracted from Front Fareast (FFE) Magic Card +//! and Super Magic Card disks (v3.2.0). +//! +//! Written from the vendored `NESdev` pages `INES_Mapper_006`, +//! `INES_Mapper_008`, `INES_Mapper_017` and the page they all point to for the +//! hardware, `Super_Magic_Card`; no reference-emulator source was read. +//! +//! These are not cartridge boards. They are copier RAM cartridges: the game +//! is loaded from a floppy into the Super Magic Card's 512 KiB of PRG DRAM and +//! 256 KiB of CHR-RAM, then run in "Play mode". The three iNES numbers only +//! say which state the card's registers start in: +//! +//! | iNES | `$4500` | `$42FF` (1M mode) | 2M/4M | other | +//! |---|---|---|---|---| +//! | 6, submapper *n* (iNES 1.0 = 1) | `$42` | `n << 5`, H/V from the header | disabled (`$43FF = $00`) | | +//! | 8 | as 6 submapper 4 ("a synonym of Mapper 6 submapper 4") | | | | +//! | 17 | `$47` (1 KiB CHR) | `$20`, H/V | 4M (`$43FC = $00`) | `$4504-$4507` = the last four 8 KiB banks | +//! +//! This module models the card in Play mode, which is all an extracted game +//! uses. GUI mode, pass-through mode, the parallel port and the floppy +//! controller are the card's own firmware's business and are not modelled; a +//! write that leaves Play mode is stored but changes nothing. +//! +//! ## Memory (Play mode) +//! +//! - `$5000-$5FFF`: 4 KiB scratch RAM. +//! - `$6000-$7FFF`: an 8 KiB window into 32 KiB WRAM (`$4500` bits 4-5). +//! - `$8000-$FFFF`: a 32 KiB view of the 512 KiB PRG DRAM, banked by one of +//! the eight latch modes, the 2M mode or the 4M mode. With the DRAM +//! write-enabled (`$42Fx` A1 = 0) a CPU write there stores into the DRAM and +//! the latch is off. +//! - `$0000-$1FFF`: CHR-RAM in one 8 KiB bank of four, or eight 1 KiB banks +//! (`$4510-$4517`) in 1 KiB mode, or MMC4-style 4 KiB banks. +//! - Nametables: CIRAM with the `$42Fx` mirroring (1scA, 1scB, V, H), or, with +//! `$4500` bit 1 clear, four 1 KiB CHR-RAM pages (`$4518-$451B`). +//! +//! The image's PRG is loaded at the start of the DRAM and its CHR at the start +//! of the CHR-RAM, as the copier would. +//! +//! ## Registers +//! +//! `$42FC-$42FF` (A1 = protect, A0 and D4 = mirroring type `A0 << 1 | D4`, +//! D7-D5 = latch mode); `$43FC-$43FF` (A0 = 2M/4M off, A1 = 2M rather than +//! 4M, D1-D0 = CHR bank); `$4500` (mode: CHR 1 KiB, CIRAM nametables, MMC4 +//! off, IRQ source, WRAM bank, Play, pass-through); `$4501` IRQ off and +//! acknowledge; `$4502`/`$4503` IRQ counter, `$4503` enabling it; `$4504-$4507` +//! 4M PRG banks; `$4510-$451B` 1 KiB CHR and nametable banks. Reads: `$4500` +//! and `$4501` state registers. The mirroring-type encoding is taken from the +//! two initial values the mapper pages give: `$42FF` with D4 = 0 is vertical +//! (type 2) and with D4 = 1 horizontal (type 3), which only `A0 << 1 | D4` +//! satisfies. +//! +//! ## IRQs +//! +//! The card's 16-bit up-counter, clocked by M2 or by every PPU A12 rise +//! (unfiltered), raises an IRQ when it wraps from `$FFFF` to `$0000`. And +//! because the older Magic Cards had no counter, "a few games abuse the FDS +//! Disk Data IRQ": with `$4025` bit 7 set the FDS RAM adapter the card sits on +//! raises an IRQ every 1792 master clocks (149 1/3 CPU cycles), acknowledged by +//! a `$4024` write. Both are modelled, since the adapter is always present +//! under the card. +//! +//! ## Trainers +//! +//! A Doctor-format trainer arrives as the iNES trainer. Mapper 6 loads it at +//! `$7000-$71FF` (WRAM bank 0) and, on a hard reset, "JSRs to `$7003` before +//! `JMPing` to the game's reset vector". Mapper 17 loads it at `$7000`, +//! `$5D00`, `$5E00` or `$5F00` (NES 2.0 submapper 0-3) and JMPs to it instead +//! of the reset vector. The card's BIOS did this; here the board answers the +//! power-on reset-vector fetch with the trainer's address (mapper 17), or with +//! a six-byte overlay at `$7200` holding `JSR $7003 / JMP ($FFFC)` that +//! disappears once its last byte has been fetched (mapper 6). A soft reset +//! (`Mapper::reset`) uses the ordinary vector, as the page specifies the call +//! only "on a hard reset". +//! +//! ## What the pages leave open +//! +//! - The initial `$4510-$4517` values for mapper 17 (not given): 0-7. +//! - What "the 4M registers function as mirrors of the 2M banking mode's +//! registers" when 4M mode is off means: a `$4504-$4507` write then also +//! loads the matching 2M register's PRG bank. In 4M mode, `$8000-$FFFF` +//! writes still load the 2M registers, which "accept values even when ... +//! not active", without moving the 4M banks. +//! - Whether the 2M registers latch while the DRAM is write-enabled: they do +//! not, like the latch they overlap. + +#![allow( + clippy::cast_possible_truncation, + clippy::missing_const_for_fn, + clippy::struct_excessive_bools, + clippy::too_many_lines +)] + +use crate::cartridge::Mirroring; +use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name}; +use alloc::{boxed::Box, format, vec, vec::Vec}; + +const PRG_DRAM: usize = 512 * 1024; +const CHR_RAM: usize = 256 * 1024; +const WRAM: usize = 32 * 1024; +const SCRATCH: usize = 4 * 1024; +const BANK_8K: usize = 0x2000; +const BANK_16K: usize = 0x4000; +const BANK_32K: usize = 0x8000; +const TRAINER_LEN: usize = 512; +/// The mapper 6 hard-reset stub's address: just past the trainer. +const STUB_ADDR: u16 = 0x7200; +/// `JSR $7003 ; JMP ($FFFC)`. +const STUB: [u8; 6] = [0x20, 0x03, 0x70, 0x6C, 0xFC, 0xFF]; +/// The FDS adapter's disk IRQ period, 1792 master clocks, in thirds of a CPU +/// cycle (12 master clocks per CPU cycle: 1792 / 12 = 448 / 3). +const FDS_IRQ_THIRDS: u16 = 448; +const SAVE_STATE_VERSION: u8 = 1; + +/// Which iNES number the image carried (it decides only the initial state). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfeBoard { + /// Mapper 6 with the given latch mode (NES 2.0 submapper; 1 on iNES 1.0). + /// Mapper 8 is mapper 6 with latch mode 4. + MagicCard(u8), + /// Mapper 17 with the given trainer-address submapper (0-3). + SuperMagicCard(u8), +} + +/// The Super Magic Card in Play mode. See the module docs. +pub struct SuperMagicCard { + board: FfeBoard, + prg: Box<[u8]>, + chr: Box<[u8]>, + wram: Box<[u8]>, + scratch: Box<[u8]>, + // $42Fx: latch mode, write-protect, mirroring type. + latch_mode: u8, + prg_protected: bool, + mirroring_type: u8, + /// The latch at `$8000-$FFFF` (latch-based modes). + latch: u8, + // $43Fx. + mode_2m4m_enabled: bool, + mode_2m: bool, + /// The common 8 KiB CHR bank (`CC`) used by the 2M and 4M modes. + chr_cc: u8, + /// 2M registers (`$8000/$A000/$C000/$E000`, D7-D2). + reg_2m: [u8; 4], + /// 4M registers (`$4504-$4507`, D5-D0). + reg_4m: [u8; 4], + /// `$4500`. + smc_mode: u8, + /// `$4510-$451B`: eight CHR and four nametable 1 KiB banks. + chr_1k: [u8; 12], + /// MMC4 latches for `$0000-$0FFF` and `$1000-$1FFF` (`false` = `$FD`). + mmc4_latch: [bool; 2], + // IRQ counter. + irq_counter: u16, + irq_enabled: bool, + irq_pending: bool, + // FDS adapter disk IRQ. + fds_irq_enabled: bool, + fds_irq_thirds: u16, + fds_irq_pending: bool, + /// Power-on reset vector override (the trainer address, or the stub). + boot_vector: Option, + /// The mapper 6 stub is mapped at `$7200` until its last byte is fetched. + stub_live: bool, + last_a12: bool, +} + +impl SuperMagicCard { + /// Build the card for `board`, loading the image's PRG into the DRAM, its + /// CHR into the CHR-RAM and its trainer (if any) where the board's page + /// says, then setting the registers to the page's initial values. + /// + /// # Errors + /// + /// [`MapperError::Invalid`] when the PRG image is empty, larger than the + /// 512 KiB DRAM or not a multiple of 8 KiB, or the CHR image is larger than + /// the 256 KiB CHR-RAM. + pub fn new( + board: FfeBoard, + prg_rom: &[u8], + chr_rom: &[u8], + trainer: Option<&[u8]>, + vertical: bool, + ) -> Result { + if prg_rom.is_empty() || prg_rom.len() > PRG_DRAM || !prg_rom.len().is_multiple_of(BANK_8K) + { + return Err(MapperError::Invalid(format!( + "FFE PRG image size {} is not 8 KiB-aligned within the card's 512 KiB", + prg_rom.len() + ))); + } + if chr_rom.len() > CHR_RAM { + return Err(MapperError::Invalid(format!( + "FFE CHR image size {} exceeds the card's 256 KiB of CHR-RAM", + chr_rom.len() + ))); + } + let mut prg = vec![0u8; PRG_DRAM].into_boxed_slice(); + prg[..prg_rom.len()].copy_from_slice(prg_rom); + let mut chr = vec![0u8; CHR_RAM].into_boxed_slice(); + chr[..chr_rom.len()].copy_from_slice(chr_rom); + let mut m = Self { + board, + prg, + chr, + wram: vec![0u8; WRAM].into_boxed_slice(), + scratch: vec![0u8; SCRATCH].into_boxed_slice(), + latch_mode: 0, + prg_protected: true, + mirroring_type: 2, + latch: 0, + mode_2m4m_enabled: false, + mode_2m: false, + chr_cc: 0, + reg_2m: [0; 4], + reg_4m: [0; 4], + smc_mode: 0, + chr_1k: [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0], + mmc4_latch: [false; 2], + irq_counter: 0, + irq_enabled: false, + irq_pending: false, + fds_irq_enabled: false, + fds_irq_thirds: 0, + fds_irq_pending: false, + boot_vector: None, + stub_live: false, + last_a12: false, + }; + let h_bit = if vertical { 0x00 } else { 0x10 }; + match board { + FfeBoard::MagicCard(sub) => { + m.cpu_write(0x4500, 0x42); + m.cpu_write(0x42FF, ((sub & 0x07) << 5) | h_bit); + m.cpu_write(0x43FF, 0x00); + if let Some(t) = trainer { + m.wram[0x1000..0x1000 + TRAINER_LEN].copy_from_slice(&t[..TRAINER_LEN]); + m.boot_vector = Some(STUB_ADDR); + m.stub_live = true; + } + } + FfeBoard::SuperMagicCard(sub) => { + m.cpu_write(0x4500, 0x47); + m.cpu_write(0x42FF, 0x20 | h_bit); + m.cpu_write(0x43FC, 0x00); + let n = (prg_rom.len() / BANK_8K) as u8; + for i in 0..4u8 { + m.cpu_write(0x4504 + u16::from(i), n.wrapping_sub(4 - i)); + } + if let Some(t) = trainer { + let at: u16 = match sub { + 1 => 0x5D00, + 2 => 0x5E00, + 3 => 0x5F00, + _ => 0x7000, + }; + for (i, &b) in t[..TRAINER_LEN].iter().enumerate() { + m.store_low(at + i as u16, b); + } + m.boot_vector = Some(at); + } + } + } + Ok(m) + } + + /// Store into scratch RAM or WRAM (trainer loading, which crosses + /// `$5FFF -> $6000` for submapper 3). + fn store_low(&mut self, addr: u16, value: u8) { + match addr { + 0x5000..=0x5FFF => self.scratch[usize::from(addr - 0x5000)] = value, + 0x6000..=0x7FFF => { + let off = self.wram_offset(addr); + self.wram[off] = value; + } + _ => {} + } + } + + fn wram_offset(&self, addr: u16) -> usize { + usize::from((self.smc_mode >> 4) & 0x03) * BANK_8K + usize::from(addr & 0x1FFF) + } + + fn chr_1k_mode(&self) -> bool { + self.smc_mode & 0x01 != 0 + } + + fn ciram_nametables(&self) -> bool { + self.smc_mode & 0x02 != 0 + } + + fn mmc4_mode(&self) -> bool { + self.chr_1k_mode() && self.smc_mode & 0x04 == 0 + } + + fn irq_from_a12(&self) -> bool { + self.smc_mode & 0x08 != 0 + } + + /// The DRAM offset for CPU `addr` (`$8000-$FFFF`) in Play mode. + fn prg_offset(&self, addr: u16) -> usize { + let slot = usize::from((addr >> 13) & 0x03); + let in8 = usize::from(addr & 0x1FFF); + if self.mode_2m4m_enabled { + let bank = if self.mode_2m { + usize::from(self.reg_2m[slot]) + } else { + usize::from(self.reg_4m[slot] & 0x3F) + }; + return (bank * BANK_8K + in8) % PRG_DRAM; + } + let upper = addr >= 0xC000; + let in16 = usize::from(addr & 0x3FFF); + let l = usize::from(self.latch); + let bank16 = |b: usize| (b * BANK_16K + in16) % PRG_DRAM; + let bank32 = |b: usize| (b * BANK_32K + usize::from(addr & 0x7FFF)) % PRG_DRAM; + match self.latch_mode { + 0 => bank16(if upper { 7 } else { l & 0x07 }), + 1 => bank16(if upper { 7 } else { (l >> 2) & 0x0F }), + 2 => bank16(if upper { 15 } else { l & 0x0F }), + 3 => bank16(if upper { l & 0x0F } else { 15 }), + 4 => bank32((l >> 4) & 0x03), + _ => bank32(3), + } + } + + /// The 8 KiB CHR bank in 8 KiB mode. + fn chr_8k_bank(&self) -> usize { + if self.mode_2m4m_enabled { + return usize::from(self.chr_cc & 0x03); + } + let l = usize::from(self.latch); + match self.latch_mode { + 1 | 4 | 5 => l & 0x03, + 3 => (l >> 4) & 0x03, + 6 => l & 0x01, + _ => 0, + } + } + + /// CHR-RAM is write-protected in latch modes 4-7, whatever banking mode + /// is active ("except CHR memory protection, which is still decided by + /// the 1M Mode Register"). + fn chr_writable(&self) -> bool { + self.latch_mode < 4 + } + + fn chr_offset(&self, addr: u16) -> usize { + let addr = usize::from(addr & 0x1FFF); + if self.mmc4_mode() { + let half = addr >> 12; + let reg = half * 4 + usize::from(self.mmc4_latch[half]) * 2; + let bank4 = usize::from(self.chr_1k[reg] >> 2); + (bank4 * 0x1000 + (addr & 0x0FFF)) % CHR_RAM + } else if self.chr_1k_mode() { + usize::from(self.chr_1k[addr >> 10]) * 0x400 + (addr & 0x03FF) + } else { + (self.chr_8k_bank() * BANK_8K + addr) % CHR_RAM + } + } + + /// MMC4 mode: a pattern read of `$xFD8-$xFDF` or `$xFE8-$xFEF` switches + /// that half's 4 KiB bank for the reads that follow. + fn mmc4_watch(&mut self, addr: u16) { + if !self.mmc4_mode() { + return; + } + let half = usize::from((addr >> 12) & 1); + match addr & 0x0FF8 { + 0x0FD8 => self.mmc4_latch[half] = false, + 0x0FE8 => self.mmc4_latch[half] = true, + _ => {} + } + } + + fn mirroring(&self) -> Mirroring { + match self.mirroring_type & 0x03 { + 0 => Mirroring::SingleScreenA, + 1 => Mirroring::SingleScreenB, + 2 => Mirroring::Vertical, + _ => Mirroring::Horizontal, + } + } + + fn nt_chr_offset(&self, addr: u16) -> usize { + let table = usize::from((addr >> 10) & 0x03); + usize::from(self.chr_1k[8 + table]) * 0x400 + usize::from(addr & 0x03FF) + } + + fn state_register_1(&self) -> u8 { + // W = 0 (no parallel-port byte), B = 1 (button not pressed). + 0x40 | (self.latch_mode << 3) + | ((self.mirroring_type & 0x01) << 2) + | (u8::from(self.prg_protected) << 1) + | (self.mirroring_type >> 1) + } + + fn state_register_2(&self) -> u8 { + (self.latch << 2) | (u8::from(!self.mode_2m) << 1) | u8::from(!self.mode_2m4m_enabled) + } + + fn write_register(&mut self, addr: u16, value: u8) { + match addr { + 0x42FC..=0x42FF => { + self.prg_protected = addr & 0x02 != 0; + self.mirroring_type = (((addr & 0x01) as u8) << 1) | ((value >> 4) & 0x01); + self.latch_mode = value >> 5; + } + 0x43FC..=0x43FF => { + self.mode_2m4m_enabled = addr & 0x01 == 0; + self.mode_2m = addr & 0x02 != 0; + self.chr_cc = value & 0x03; + } + 0x4500 => self.smc_mode = value, + 0x4501 => { + self.irq_enabled = false; + self.irq_pending = false; + } + 0x4502 => { + self.irq_counter = (self.irq_counter & 0xFF00) | u16::from(value); + self.irq_pending = false; + } + 0x4503 => { + self.irq_counter = (self.irq_counter & 0x00FF) | (u16::from(value) << 8); + self.irq_pending = false; + self.irq_enabled = true; + } + 0x4504..=0x4507 => { + let i = usize::from(addr - 0x4504); + self.reg_4m[i] = value & 0x3F; + // "Mirrors of the 2M registers" whenever 4M mode is not the + // active one. + if !self.mode_2m4m_enabled || self.mode_2m { + self.reg_2m[i] = value & 0x3F; + } + } + 0x4510..=0x451B => self.chr_1k[usize::from(addr - 0x4510)] = value, + _ => {} + } + } + + fn clock_irq(&mut self) { + if self.irq_enabled { + let (next, wrapped) = self.irq_counter.overflowing_add(1); + self.irq_counter = next; + if wrapped { + self.irq_pending = true; + } + } + } +} + +impl Mapper for SuperMagicCard { + fn caps(&self) -> MapperCaps { + MapperCaps::CYCLE_IRQ + } + + fn sram(&self) -> &[u8] { + &self.wram + } + + fn sram_mut(&mut self) -> &mut [u8] { + &mut self.wram + } + + fn cpu_read_unmapped(&self, addr: u16) -> bool { + !matches!(addr, 0x4500 | 0x4501 | 0x5000..=0xFFFF) + } + + fn cpu_read(&mut self, addr: u16) -> u8 { + if let Some(v) = self.boot_vector + && (addr == 0xFFFC || addr == 0xFFFD) + { + if addr == 0xFFFD { + self.boot_vector = None; + } + return v.to_le_bytes()[usize::from(addr - 0xFFFC)]; + } + if self.stub_live && (STUB_ADDR..STUB_ADDR + 6).contains(&addr) { + if addr == STUB_ADDR + 5 { + self.stub_live = false; + } + return STUB[usize::from(addr - STUB_ADDR)]; + } + match addr { + 0x4500 => self.state_register_1(), + 0x4501 => self.state_register_2(), + 0x5000..=0x5FFF => self.scratch[usize::from(addr - 0x5000)], + 0x6000..=0x7FFF => self.wram[self.wram_offset(addr)], + 0x8000..=0xFFFF => self.prg[self.prg_offset(addr)], + _ => 0, + } + } + + fn cpu_write(&mut self, addr: u16, value: u8) { + match addr { + // The FDS RAM adapter under the card. + 0x4024 => self.fds_irq_pending = false, + 0x4025 => { + self.fds_irq_enabled = value & 0x80 != 0; + self.fds_irq_thirds = 0; + if !self.fds_irq_enabled { + self.fds_irq_pending = false; + } + } + 0x4020..=0x4FFF => self.write_register(addr, value), + 0x5000..=0x7FFF => self.store_low(addr, value), + 0x8000..=0xFFFF => { + if self.prg_protected { + self.latch = value; + let slot = usize::from((addr >> 13) & 0x03); + self.reg_2m[slot] = value >> 2; + if self.mode_2m4m_enabled { + self.chr_cc = value & 0x03; + } + } else { + let off = self.prg_offset(addr); + self.prg[off] = value; + } + } + _ => {} + } + } + + fn reset(&mut self) { + // A soft reset runs the ordinary vector; the trainer call is for a + // hard reset only. + self.boot_vector = None; + self.stub_live = false; + } + + fn chr_reads_are_pure(&self) -> bool { + !self.mmc4_mode() + } + + fn ppu_read(&mut self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let v = self.chr[self.chr_offset(addr)]; + self.mmc4_watch(addr); + v + } + 0x2000..=0x3EFF if !self.ciram_nametables() => self.chr[self.nt_chr_offset(addr)], + _ => 0, + } + } + + fn ppu_write(&mut self, addr: u16, value: u8) { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF if self.chr_writable() => { + let off = self.chr_offset(addr); + self.chr[off] = value; + } + 0x2000..=0x3EFF if !self.ciram_nametables() => { + let off = self.nt_chr_offset(addr); + self.chr[off] = value; + } + _ => {} + } + } + + fn nametable_fetch(&mut self, addr: u16) -> Option { + (!self.ciram_nametables()).then(|| self.chr[self.nt_chr_offset(addr)]) + } + + fn nametable_write(&mut self, addr: u16, value: u8) -> bool { + if self.ciram_nametables() { + return false; + } + let off = self.nt_chr_offset(addr); + self.chr[off] = value; + true + } + + fn current_mirroring(&self) -> Mirroring { + if self.ciram_nametables() { + self.mirroring() + } else { + Mirroring::MapperControlled + } + } + + fn notify_a12(&mut self, level: bool) { + if level && !self.last_a12 && self.irq_from_a12() { + self.clock_irq(); + } + self.last_a12 = level; + } + + fn notify_cpu_cycle(&mut self) { + if !self.irq_from_a12() { + self.clock_irq(); + } + if self.fds_irq_enabled { + self.fds_irq_thirds += 3; + if self.fds_irq_thirds >= FDS_IRQ_THIRDS { + self.fds_irq_thirds -= FDS_IRQ_THIRDS; + self.fds_irq_pending = true; + } + } + } + + fn irq_pending(&self) -> bool { + self.irq_pending || self.fds_irq_pending + } + + fn debug_info(&self) -> MapperDebugInfo { + let (id, name) = match self.board { + FfeBoard::MagicCard(4) => (8, "FFE Magic Card, GNROM (8)"), + FfeBoard::MagicCard(_) => (6, "FFE Magic Card (6)"), + FfeBoard::SuperMagicCard(_) => (17, "FFE Super Magic Card (17)"), + }; + let mut info = MapperDebugInfo { + mapper_id: id, + name: name.into(), + mirroring: mirroring_name(self.current_mirroring()), + ..Default::default() + }; + let mode = if !self.mode_2m4m_enabled { + format!("latch {}", self.latch_mode) + } else if self.mode_2m { + "2M".into() + } else { + "4M".into() + }; + info.prg_banks.push(("mode".into(), mode)); + for slot in 0..4u16 { + let base = 0x8000 + slot * 0x2000; + info.prg_banks.push(( + format!("${base:04X}"), + format!("{:#07x}", self.prg_offset(base)), + )); + } + info.chr_banks.push(( + "CHR".into(), + if self.mmc4_mode() { + "MMC4".into() + } else if self.chr_1k_mode() { + format!("1K {:?}", &self.chr_1k[..8]) + } else { + format!("8K bank {}", self.chr_8k_bank()) + }, + )); + info.irq_state + .push(("counter".into(), format!("{:#06x}", self.irq_counter))); + info.irq_state + .push(("enabled".into(), format!("{}", self.irq_enabled))); + info.irq_state + .push(("fds".into(), format!("{}", self.fds_irq_enabled))); + info + } + + fn save_state(&self) -> Vec { + let mut out = Vec::with_capacity( + 64 + self.prg.len() + self.chr.len() + self.wram.len() + self.scratch.len(), + ); + out.push(SAVE_STATE_VERSION); + out.push(self.latch_mode); + out.push(u8::from(self.prg_protected)); + out.push(self.mirroring_type); + out.push(self.latch); + out.push(u8::from(self.mode_2m4m_enabled)); + out.push(u8::from(self.mode_2m)); + out.push(self.chr_cc); + out.extend_from_slice(&self.reg_2m); + out.extend_from_slice(&self.reg_4m); + out.push(self.smc_mode); + out.extend_from_slice(&self.chr_1k); + out.push(u8::from(self.mmc4_latch[0])); + out.push(u8::from(self.mmc4_latch[1])); + out.extend_from_slice(&self.irq_counter.to_le_bytes()); + out.push(u8::from(self.irq_enabled)); + out.push(u8::from(self.irq_pending)); + out.push(u8::from(self.fds_irq_enabled)); + out.extend_from_slice(&self.fds_irq_thirds.to_le_bytes()); + out.push(u8::from(self.fds_irq_pending)); + out.extend_from_slice(&self.boot_vector.unwrap_or(0).to_le_bytes()); + out.push(u8::from(self.boot_vector.is_some())); + out.push(u8::from(self.stub_live)); + out.push(u8::from(self.last_a12)); + out.extend_from_slice(&self.prg); + out.extend_from_slice(&self.chr); + out.extend_from_slice(&self.wram); + out.extend_from_slice(&self.scratch); + out + } + + fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + const HEAD: usize = 1 + 7 + 4 + 4 + 1 + 12 + 2 + 2 + 2 + 1 + 2 + 1 + 2 + 1 + 1 + 1; + let expected = HEAD + PRG_DRAM + CHR_RAM + WRAM + SCRATCH; + if data.len() != expected { + return Err(MapperError::WrongLength { + expected, + got: data.len(), + }); + } + if data[0] != SAVE_STATE_VERSION { + return Err(MapperError::UnsupportedVersion(data[0])); + } + let mut c = 1; + let mut byte = || { + let v = data[c]; + c += 1; + v + }; + self.latch_mode = byte() & 0x07; + self.prg_protected = byte() != 0; + self.mirroring_type = byte() & 0x03; + self.latch = byte(); + self.mode_2m4m_enabled = byte() != 0; + self.mode_2m = byte() != 0; + self.chr_cc = byte() & 0x03; + for i in 0..4 { + self.reg_2m[i] = byte(); + } + for i in 0..4 { + self.reg_4m[i] = byte(); + } + self.smc_mode = byte(); + for i in 0..12 { + self.chr_1k[i] = byte(); + } + self.mmc4_latch = [byte() != 0, byte() != 0]; + self.irq_counter = u16::from_le_bytes([byte(), byte()]); + self.irq_enabled = byte() != 0; + self.irq_pending = byte() != 0; + self.fds_irq_enabled = byte() != 0; + self.fds_irq_thirds = u16::from_le_bytes([byte(), byte()]); + self.fds_irq_pending = byte() != 0; + let vector = u16::from_le_bytes([byte(), byte()]); + self.boot_vector = (byte() != 0).then_some(vector); + self.stub_live = byte() != 0; + self.last_a12 = byte() != 0; + let mut rest = &data[c..]; + for buf in [ + &mut self.prg[..], + &mut self.chr[..], + &mut self.wram[..], + &mut self.scratch[..], + ] { + let (now, later) = rest.split_at(buf.len()); + buf.copy_from_slice(now); + rest = later; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + /// An image through `crate::parse`: `prg_16k` x 16 KiB PRG whose every + /// 16 KiB page opens with its index, `chr_8k` x 8 KiB CHR likewise, NES + /// 2.0 with `submapper` when given, and an optional 512-byte trainer. + fn image( + mapper: u8, + prg_16k: usize, + chr_8k: usize, + submapper: Option, + trainer: bool, + ) -> Vec { + let mut v = vec![0u8; 16]; + v[0..4].copy_from_slice(b"NES\x1A"); + v[4] = prg_16k as u8; + v[5] = chr_8k as u8; + v[6] = ((mapper & 0x0F) << 4) | 0x01 | if trainer { 0x04 } else { 0 }; + v[7] = mapper & 0xF0; + if let Some(s) = submapper { + v[7] |= 0x08; + v[8] = s << 4; + } + if trainer { + let mut t = vec![0xEAu8; TRAINER_LEN]; + t[0] = 0x4C; // a recognisable first byte + v.extend_from_slice(&t); + } + for p in 0..prg_16k { + let mut page = vec![0u8; BANK_16K]; + page[0] = p as u8; + page[0x2000] = 0x80 | p as u8; // second 8 KiB of the page + page[0x3FFC] = 0x34; + page[0x3FFD] = 0x12; + v.extend_from_slice(&page); + } + for b in 0..chr_8k { + let mut bank = vec![0u8; BANK_8K]; + bank[0] = b as u8; + bank[0x400] = 0x40 | b as u8; + v.extend_from_slice(&bank); + } + v + } + + fn board( + mapper: u8, + prg_16k: usize, + chr_8k: usize, + submapper: Option, + trainer: bool, + ) -> Box { + parse(&image(mapper, prg_16k, chr_8k, submapper, trainer)) + .unwrap_or_else(|e| panic!("mapper {mapper} must parse: {e:?}")) + .1 + } + + #[test] + fn m6_ines_defaults_to_un1rom_chrsw_with_page_7_fixed() { + let mut m = board(6, 16, 0, None, false); + assert_eq!(m.cpu_read(0xC000), 7, "latch mode 1: page 7 fixed"); + m.cpu_write(0x8000, 0b0010_1110); // PRG 11 (D5-D2), CHR 2 (D1-D0) + assert_eq!(m.cpu_read(0x8000), 11); + assert_eq!(m.cpu_read(0xC000), 7); + m.ppu_write(0x0005, 0x99); + assert_eq!(m.ppu_read(0x0005), 0x99, "CHR-RAM writable in mode 1"); + m.cpu_write(0x8000, 0b0010_1100); // CHR 0 + assert_ne!(m.ppu_read(0x0005), 0x99, "a different 8 KiB bank"); + assert_eq!(m.current_mirroring(), Mirroring::Vertical, "header V"); + } + + #[test] + fn m6_submappers_select_the_latch_modes() { + // 0 UNROM, 2 UOROM, 3 reverse UOROM, 4 GNROM, 5-7 fixed 32 KiB #3. + let mut m = board(6, 32, 0, Some(0), false); + m.cpu_write(0x8000, 0xFD); + assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (5, 7)); + let mut m = board(6, 32, 0, Some(2), false); + m.cpu_write(0x8000, 0x0C); + assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (12, 15)); + let mut m = board(6, 32, 0, Some(3), false); + m.cpu_write(0x8000, 0x1C); // CC = 1, PPPP = 12 + assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (15, 12)); + let mut m = board(6, 32, 4, Some(4), false); + m.cpu_write(0x8000, 0x22); // PP = 2, CC = 2 + assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (4, 5)); + assert_eq!(m.ppu_read(0x0000), 2); + m.ppu_write(0x0000, 0x77); + assert_eq!(m.ppu_read(0x0000), 2, "GNROM mode: CHR write-protected"); + for sub in 5..=7 { + let mut m = board(6, 32, 4, Some(sub), false); + m.cpu_write(0x8000, 0x03); + assert_eq!( + (m.cpu_read(0x8000), m.cpu_read(0xC000)), + (6, 7), + "sub {sub}" + ); + let want = match sub { + 5 => 3, + 6 => 1, + _ => 0, + }; + assert_eq!(m.ppu_read(0x0000), want, "sub {sub} CHR"); + } + } + + #[test] + fn m8_is_m6_submapper_4() { + let mut m = board(8, 8, 4, None, false); + m.cpu_write(0x8000, 0x11); // PP = 1, CC = 1 + assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (2, 3)); + assert_eq!(m.ppu_read(0x0000), 1); + } + + #[test] + fn mirroring_types_and_write_enable() { + let mut m = board(6, 16, 0, None, false); + m.cpu_write(0x42FE, 0x20); // A1 = 1, A0 = 0, D4 = 0: type 0 + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA); + m.cpu_write(0x42FE, 0x30); // type 1 + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB); + m.cpu_write(0x42FF, 0x30); // type 3 + assert_eq!(m.current_mirroring(), Mirroring::Horizontal); + // A1 = 0: the DRAM is writable and the latch is off. + m.cpu_write(0x42FD, 0x20); + m.cpu_write(0x8001, 0xAB); + assert_eq!(m.cpu_read(0x8001), 0xAB, "written into PRG DRAM"); + m.cpu_write(0x42FF, 0x20); + assert_eq!(m.cpu_read(0x8001), 0xAB, "latch untouched by the write"); + assert_eq!(m.cpu_read(0x4500) & 0x3F, (1 << 3) | 0x02 | 0x01); + } + + #[test] + fn m17_starts_in_4m_mode_with_the_last_banks_and_1k_chr() { + let mut m = board(17, 16, 16, None, false); + // 32 8 KiB banks: $4504-$4507 = 28..31. + assert_eq!( + m.cpu_read(0x8000), + 14, + "bank 28 = 16 KiB page 14, first half" + ); + assert_eq!(m.cpu_read(0xA000), 0x8E, "bank 29"); + assert_eq!(m.cpu_read(0xE000), 0x8F, "bank 31"); + m.cpu_write(0x4505, 3); + assert_eq!(m.cpu_read(0xA000), 0x81); + // $8000 writes do not move the 4M banks. + m.cpu_write(0x8000, 0xFC); + assert_eq!(m.cpu_read(0x8000), 14); + // 1 KiB CHR: $4511 picks the 1 KiB at $0400. + m.cpu_write(0x4511, 9); // 8 KiB bank 1, second 1 KiB + assert_eq!(m.ppu_read(0x0400), 0x40 | 1); + // 2M mode: the four $8000-$FFFF registers, D7-D2. + m.cpu_write(0x43FE, 0x00); // A0 = 0 enable, A1 = 1: 2M + m.cpu_write(0xC000, 5 << 2); + assert_eq!( + m.cpu_read(0xC000), + 0x82, + "2M: 8 KiB bank 5 = page 2's upper half" + ); + } + + #[test] + fn chr_nametables_when_n_is_clear() { + let mut m = board(17, 16, 16, None, false); + assert_eq!(m.nametable_fetch(0x2000), None, "N = 1: CIRAM"); + m.cpu_write(0x4500, 0x45); // N = 0 + m.cpu_write(0x4518, 1); // $2000 <- CHR 1 KiB page 1 + m.cpu_write(0x451B, 8); // $2C00 <- page 8 + assert_eq!(m.nametable_fetch(0x2000), Some(0x40)); + assert_eq!(m.nametable_fetch(0x2C00), Some(1)); + assert!(m.nametable_write(0x2C01, 0x5A)); + assert_eq!(m.nametable_fetch(0x2C01), Some(0x5A)); + assert_eq!(m.current_mirroring(), Mirroring::MapperControlled); + } + + #[test] + fn mmc4_mode_switches_4k_banks_after_the_latch_tiles() { + let mut m = board(17, 16, 16, None, false); + m.cpu_write(0x4500, 0x43); // C = 1, m = 0: MMC4 mode + m.cpu_write(0x4510, 2 << 2); // $FD bank for $0000: 4 KiB bank 2 + m.cpu_write(0x4512, 4 << 2); // $FE bank: 4 KiB bank 4 (= 8 KiB bank 2) + assert_eq!(m.ppu_read(0x0000), 1, "4 KiB bank 2 = 8 KiB bank 1"); + let _ = m.ppu_read(0x0FE8); + assert_eq!(m.ppu_read(0x0000), 2, "after $0FE8: the $FE bank"); + assert!(!m.chr_reads_are_pure()); + let _ = m.ppu_read(0x0FD8); + assert_eq!(m.ppu_read(0x0000), 1); + } + + #[test] + fn irq_counter_wraps_and_fds_timer_fires() { + let mut m = board(6, 16, 0, None, false); + m.cpu_write(0x4502, 0xFE); + m.cpu_write(0x4503, 0xFF); // $FFFE, enabled, M2 source + m.notify_cpu_cycle(); + assert!(!m.irq_pending()); + m.notify_cpu_cycle(); + assert!(m.irq_pending(), "$FFFF -> $0000"); + m.cpu_write(0x4501, 0); + assert!(!m.irq_pending()); + // PA12 source. + m.cpu_write(0x4500, 0x4A); + m.cpu_write(0x4502, 0xFF); + m.cpu_write(0x4503, 0xFF); + m.notify_cpu_cycle(); + assert!(!m.irq_pending(), "M2 does not clock it"); + m.notify_a12(true); + assert!(m.irq_pending(), "one PA12 rise"); + m.cpu_write(0x4501, 0); + // FDS adapter: 149 1/3 CPU cycles. + m.cpu_write(0x4025, 0x80); + for _ in 0..149 { + m.notify_cpu_cycle(); + } + assert!(!m.irq_pending()); + m.notify_cpu_cycle(); + assert!(m.irq_pending(), "150th cycle"); + m.cpu_write(0x4024, 0); + assert!(!m.irq_pending(), "$4024 acknowledges"); + } + + #[test] + fn trainers_run_on_a_hard_reset_only() { + // Mapper 6: JSR $7003 then JMP ($FFFC). + let mut m = board(6, 16, 0, None, true); + assert_eq!(m.cpu_read(0x7000), 0x4C, "trainer at $7000"); + assert_eq!(m.cpu_read(0xFFFC), 0x00); + assert_eq!(m.cpu_read(0xFFFD), 0x72, "power-on vector -> $7200 stub"); + let stub: Vec = (0..6).map(|i| m.cpu_read(0x7200 + i)).collect(); + assert_eq!(stub, STUB); + assert_eq!(m.cpu_read(0x7200), 0, "the stub is gone"); + assert_eq!(m.cpu_read(0xFFFD), 0x12, "the game's own vector"); + // Mapper 17 submapper 2: JMP to the trainer at $5E00. + let mut m = board(17, 16, 16, Some(2), true); + assert_eq!(m.cpu_read(0x5E00), 0x4C); + assert_eq!((m.cpu_read(0xFFFC), m.cpu_read(0xFFFD)), (0x00, 0x5E)); + // A soft reset uses the ordinary vector. + let mut m = board(17, 16, 16, None, true); + m.reset(); + assert_ne!(m.cpu_read(0xFFFD), 0x70); + } + + #[test] + fn state_round_trips() { + let mut a = board(17, 16, 16, None, true); + a.cpu_write(0x4506, 7); + a.cpu_write(0x6000, 0x31); + a.cpu_write(0x5123, 0x32); + a.ppu_write(0x0100, 0x33); + let blob = a.save_state(); + let mut b = board(17, 16, 16, None, true); + b.load_state(&blob).unwrap(); + assert_eq!(b.save_state(), blob); + assert_eq!(b.cpu_read(0xC000), a.cpu_read(0xC000)); + assert_eq!(b.cpu_read(0x6000), 0x31); + assert_eq!(b.cpu_read(0x5123), 0x32); + assert_eq!(b.ppu_read(0x0100), 0x33); + assert!(b.load_state(&blob[1..]).is_err()); + } +} diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 5d727754..14b3e01d 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -47,6 +47,7 @@ mod a12_filter; mod bmc_simple; mod cartridge; mod fds; +mod ffe_super_magic_card; mod header; mod homebrew_boards; mod jaleco_discrete; @@ -158,6 +159,7 @@ pub use fds::{ DISK_BYTE_CYCLES, FDS_SIDE_LEN, Fds, FdsDisk, FdsQuirk, FdsTraceRec, HEAD_RESEEK_CYCLES, fds_crc32, parse_fds, quirk_for_crc, }; +pub use ffe_super_magic_card::{FfeBoard, SuperMagicCard}; pub use header::{ ExpansionDevice, ExtendedConsoleType, Header, VsHardwareType, parse_header, serialize_header_preserving, @@ -1620,6 +1622,31 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { GoldenGame235::new(prg_rom, &chr_rom) .map_err(|e| RomError::InvalidConfig(e.to_string()))?, ), + // 6 / 8 / 17: games extracted from FFE Magic Card / Super Magic Card + // disks, run on a model of the card (`ffe_super_magic_card.rs`). The + // iNES number and submapper pick only the card's initial registers; + // the Doctor-format trainer, which `parse` otherwise skips, is handed + // to the card to load and call. + 6 | 8 | 17 => { + let trainer = h + .has_trainer + .then(|| &bytes[header::HEADER_LEN..header::HEADER_LEN + header::TRAINER_LEN]); + let board = match h.mapper_id { + 8 => FfeBoard::MagicCard(4), + 6 => FfeBoard::MagicCard(if h.is_nes2 { h.submapper & 0x07 } else { 1 }), + _ => FfeBoard::SuperMagicCard(if h.is_nes2 { h.submapper & 0x03 } else { 0 }), + }; + Box::new( + SuperMagicCard::new( + board, + &prg_rom, + &chr_rom, + trainer, + h.mirroring == Mirroring::Vertical, + ) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?, + ) + } other => return Err(RomError::UnsupportedMapper(other)), }; diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index 5bbbb58e..5f3c0c04 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -142,7 +142,7 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // `docs/compatibility.md`). A separate arm so each release's batch // reads as one list; the bodies match on purpose. #[allow(clippy::match_same_arms)] - 158 | 172 | 173 | 207 | 230 | 235 | 252 => Some(MapperTier::Curated), + 6 | 8 | 17 | 158 | 172 | 173 | 207 | 230 | 235 | 252 => Some(MapperTier::Curated), // --- Tier 2 / BestEffort: the 26 reference-ported long-tail families // that lack a *cleanly-booting* redistributable ROM dump (so they cannot @@ -251,7 +251,7 @@ mod tests { 225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253, // v2.9.6 "Roster". 12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249, 172, 173, 158, 207, 252, - 230, 235, // v3.2.0 + 230, 235, 6, 8, 17, // v3.2.0 ]; #[test] diff --git a/crates/rustynes-test-harness/tests/external_coverage.rs b/crates/rustynes-test-harness/tests/external_coverage.rs index d266e25c..b38fef65 100644 --- a/crates/rustynes-test-harness/tests/external_coverage.rs +++ b/crates/rustynes-test-harness/tests/external_coverage.rs @@ -169,14 +169,6 @@ const UNSUPPORTED: &[(&str, &str)] = &[ "mapper-146-Sachen-NINA/Lucky 777 (Sachen) [!].zip", "malformed: mapper 146 PRG is a multiple of 32 KiB and this image has 16 KiB", ), - ( - "mapper-072-Jaleco72/Doraemon World 3 by Kiku (Doraemon Hack).nes", - "dirty iNES 1.0 tail (\"DiskDude!\"): the masked header is mapper 8 (FFE), not implemented", - ), - ( - "mapper-072-Jaleco72/Doraemon World 3 by Kiku (Doraemon Hack).zip", - "dirty iNES 1.0 tail (\"DiskDude!\"): the masked header is mapper 8 (FFE), not implemented", - ), ( "vs-system/VS Castlevania Hack.nes", "not an iNES image (no NES<1A> magic)", @@ -195,6 +187,9 @@ const UNSUPPORTED: &[(&str, &str)] = &[ /// database's NES 2.0 guard made seven of them render, two Doraemon hacks moved /// to `UNSUPPORTED` as the mapper 8 images they are, and one documented /// blank, Famicom Jump II, was added. +/// (v3.2.0 implements mapper 8, the FFE Magic Card's GNROM mode, and those two +/// Doraemon World 3 images left `UNSUPPORTED`: they boot to their title and +/// stage-select screens and carry ordinary snapshots.) const KNOWN_BLANK: &[&str] = &[ "fds/Akumajou Dracula (Japan) (Rev 2) (Disk Writer).fds", "mapper-009-MMC2/Gradius II (J) (VC).zip", diff --git a/crates/rustynes-test-harness/tests/external_extended.rs b/crates/rustynes-test-harness/tests/external_extended.rs index 3b86166d..fb388e08 100644 --- a/crates/rustynes-test-harness/tests/external_extended.rs +++ b/crates/rustynes-test-harness/tests/external_extended.rs @@ -1574,3 +1574,107 @@ fn extended_m235_260_in_1_mario_bros() { "extended_m235_260_in_1_mario_bros", ); } + +/// FFE Magic Card / Super Magic Card extractions (mappers 6, 8, 17): START +/// at f1000 and f1400, A at f1800, START at f2200, each held six frames, then +/// run to f2600. That reaches play or the first in-game screen on these +/// titles, past the menus the trainers and the card's IRQs drive. +const FFE_INTO_PLAY: InputScript = InputScript::Held { + presses: &[ + (1000, Buttons::START), + (1400, Buttons::START), + (1800, Buttons::A), + (2200, Buttons::START), + ], + hold: 6, + resets: &[], + frames: 2600, +}; + +/// *Wai Wai World* (mapper 6) has a longer intro and character menu: the +/// script answers it with A and START, then holds RIGHT from f3500 to walk +/// into the first stage, in play at f3800. +const FFE_WAI_WAI_WORLD: InputScript = InputScript::Held { + presses: &[ + (1000, Buttons::START), + (1300, Buttons::START), + (1600, Buttons::A), + (1900, Buttons::A), + (2200, Buttons::START), + (2500, Buttons::A), + (2800, Buttons::START), + (3100, Buttons::A), + (3400, Buttons::RIGHT), + (3410, Buttons::RIGHT), + (3420, Buttons::RIGHT), + (3430, Buttons::RIGHT), + (3440, Buttons::RIGHT), + (3450, Buttons::RIGHT), + ], + hold: 10, + resets: &[], + frames: 3800, +}; + +#[test] +fn extended_m006_ganbare_goemon() { + check( + "mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].zip", + FFE_INTO_PLAY, + "extended_m006_ganbare_goemon", + ); +} + +#[test] +fn extended_m006_cosmo_police_galivan() { + check( + "mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].zip", + FFE_INTO_PLAY, + "extended_m006_cosmo_police_galivan", + ); +} + +#[test] +fn extended_m006_wai_wai_world() { + check( + "mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].zip", + FFE_WAI_WAI_WORLD, + "extended_m006_wai_wai_world", + ); +} + +#[test] +fn extended_m008_mermaids_of_atlantis() { + check( + "mapper-008-FFE-GNROM/Mermaids of Atlantis - The Riddle of the Magic Bubble (AVE) [T+Rus_Guyver].zip", + FFE_INTO_PLAY, + "extended_m008_mermaids_of_atlantis", + ); +} + +#[test] +fn extended_m017_kaiketsu_yanchamaru_2() { + check( + "mapper-017-FFE-SuperMagicCard/Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE].zip", + FFE_INTO_PLAY, + "extended_m017_kaiketsu_yanchamaru_2", + ); +} + +#[test] +fn extended_m017_juvei_quest() { + check( + "mapper-017-FFE-SuperMagicCard/Juvei Quest (J) [hFFE].zip", + FFE_INTO_PLAY, + "extended_m017_juvei_quest", + ); +} + +#[test] +fn extended_m017_dragon_ball_z_iii() { + check( + "mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].zip", + FFE_INTO_PLAY, + "extended_m017_dragon_ball_z_iii", + ); +} diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Cosmo_Police_Galivan_J_hFFE_p1_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Cosmo_Police_Galivan_J_hFFE_p1_zip.snap new file mode 100644 index 00000000..fdfb179f --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Cosmo_Police_Galivan_J_hFFE_p1_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].zip +rom_sha256=089e5314970fb3e00e4d572b52686804358958a20d6458f6d8555e48e08a8c24 +frames=1141 +fb_bytes=245760 +cycles=33949782 +audio_samples=836505 +audio_fnv1a64=4d177bd09a69c627 +checkpoint f900 fb_fnv1a64=865fcaa1d9e8c5cb +checkpoint f1100 fb_fnv1a64=865fcaa1d9e8c5cb diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Ganbare_Goemon_Karakuri_Douchuu_J_hFFE_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Ganbare_Goemon_Karakuri_Douchuu_J_hFFE_zip.snap new file mode 100644 index 00000000..2ada5428 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Ganbare_Goemon_Karakuri_Douchuu_J_hFFE_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].zip +rom_sha256=5ad056d725c64c330ea89c138271434a6869403dd47a99451352ad88428b9de8 +frames=1141 +fb_bytes=245760 +cycles=33949781 +audio_samples=836505 +audio_fnv1a64=ce4703b3887b0ced +checkpoint f900 fb_fnv1a64=42218c76edeb1a6f +checkpoint f1100 fb_fnv1a64=b928ac5b013a80ea diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Wai_Wai_World_J_hFFE_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Wai_Wai_World_J_hFFE_zip.snap new file mode 100644 index 00000000..dc3a0d69 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_006_FFE_MagicCard_Wai_Wai_World_J_hFFE_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].zip +rom_sha256=64b80dc565962812d842ef7181b7f2cbe8da1519b4d3abfcb11e11761b636a81 +frames=1141 +fb_bytes=245760 +cycles=33949777 +audio_samples=836505 +audio_fnv1a64=d45a72bc80f50de3 +checkpoint f900 fb_fnv1a64=cb59198bcd1d1094 +checkpoint f1100 fb_fnv1a64=580cdc93dcfa4d04 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Dragon_Ball_Shen_Long_no_Nazo_J_hFFE_b1_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Dragon_Ball_Shen_Long_no_Nazo_J_hFFE_b1_zip.snap new file mode 100644 index 00000000..b7619aec --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Dragon_Ball_Shen_Long_no_Nazo_J_hFFE_b1_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-008-FFE-GNROM/Dragon Ball - Shen Long no Nazo (J) [hFFE][b1].zip +rom_sha256=37234d9d9369c617aa3f2b4be3a5df5145af49e6d5015c9975db7fd0b9fcc622 +frames=1141 +fb_bytes=245760 +cycles=33949781 +audio_samples=836505 +audio_fnv1a64=6597b5783f7b81a8 +checkpoint f900 fb_fnv1a64=856da090bd91b674 +checkpoint f1100 fb_fnv1a64=afebcf54223cb40a diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Mermaids_of_Atlantis_The_Riddle_of_the_Magic_Bubble_AVE_T_Rus_Guyver_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Mermaids_of_Atlantis_The_Riddle_of_the_Magic_Bubble_AVE_T_Rus_Guyver_zip.snap new file mode 100644 index 00000000..37f87246 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_008_FFE_GNROM_Mermaids_of_Atlantis_The_Riddle_of_the_Magic_Bubble_AVE_T_Rus_Guyver_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-008-FFE-GNROM/Mermaids of Atlantis - The Riddle of the Magic Bubble (AVE) [T+Rus_Guyver].zip +rom_sha256=018fcac8414972399cb41aeb9ba0c93451a5a067d1216bd892e668f416d449cb +frames=1141 +fb_bytes=245760 +cycles=33949771 +audio_samples=836505 +audio_fnv1a64=c653e0bf72ccba27 +checkpoint f900 fb_fnv1a64=ed9cc421d32a33d0 +checkpoint f1100 fb_fnv1a64=271daa76f225976c diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Dragon_Ball_Z_III_Ressen_Jinzou_Ningen_J_hFFE_p1_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Dragon_Ball_Z_III_Ressen_Jinzou_Ningen_J_hFFE_p1_zip.snap new file mode 100644 index 00000000..875cdf01 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Dragon_Ball_Z_III_Ressen_Jinzou_Ningen_J_hFFE_p1_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].zip +rom_sha256=424a49d3a8dd1800ed5710c4371fdc4bdf2d217c3f20276f188e4d7bdef6048f +frames=1141 +fb_bytes=245760 +cycles=33949789 +audio_samples=836505 +audio_fnv1a64=f35b2a9fe2366738 +checkpoint f900 fb_fnv1a64=5cffd91e66eeff9c +checkpoint f1100 fb_fnv1a64=d2c5cb61d590b270 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Juvei_Quest_J_hFFE_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Juvei_Quest_J_hFFE_zip.snap new file mode 100644 index 00000000..55b0fef2 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Juvei_Quest_J_hFFE_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-017-FFE-SuperMagicCard/Juvei Quest (J) [hFFE].zip +rom_sha256=755d352e081aa456229f56c39b8eb5ccbd013fb0b13f9d775e0687b2cc3bf745 +frames=1141 +fb_bytes=245760 +cycles=33949773 +audio_samples=836505 +audio_fnv1a64=cd1a8e8f5455c830 +checkpoint f900 fb_fnv1a64=aaf2f94b9b97c555 +checkpoint f1100 fb_fnv1a64=9858a696737e2e75 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Kaiketsu_Yanchamaru_2_Karakuri_Land_J_hFFE_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Kaiketsu_Yanchamaru_2_Karakuri_Land_J_hFFE_zip.snap new file mode 100644 index 00000000..12233db6 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_017_FFE_SuperMagicCard_Kaiketsu_Yanchamaru_2_Karakuri_Land_J_hFFE_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-017-FFE-SuperMagicCard/Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE].zip +rom_sha256=d2b410a12d2f2f5b2cffeca5d84b8b1fb8d720f69e76e8c65208da70c4c546a9 +frames=1141 +fb_bytes=245760 +cycles=33949783 +audio_samples=836505 +audio_fnv1a64=55f8c9e32f73708a +checkpoint f900 fb_fnv1a64=5153f17f38f0d044 +checkpoint f1100 fb_fnv1a64=2f7246198ee16d98 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack.snap new file mode 100644 index 00000000..90c83af7 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-072-Jaleco72/Doraemon World 3 by Kiku (Doraemon Hack).nes +rom_sha256=088f684b561169a6becf4120c8a8ab77a56ac15ec39f9f48c6f2aa6e535d59ae +frames=1141 +fb_bytes=245760 +cycles=33949780 +audio_samples=836505 +audio_fnv1a64=8a82c7fae996e821 +checkpoint f900 fb_fnv1a64=67194bb6b3442b94 +checkpoint f1100 fb_fnv1a64=ef2c3015997fec78 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack_zip.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack_zip.snap new file mode 100644 index 00000000..4d4c2b36 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack_zip.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-072-Jaleco72/Doraemon World 3 by Kiku (Doraemon Hack).zip +rom_sha256=97ce03093c71fd82cb2ca21b10e11d28b101d0f3ddf49aa05dfd6e13afb44b19 +frames=1141 +fb_bytes=245760 +cycles=33949780 +audio_samples=836505 +audio_fnv1a64=8a82c7fae996e821 +checkpoint f900 fb_fnv1a64=67194bb6b3442b94 +checkpoint f1100 fb_fnv1a64=ef2c3015997fec78 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_cosmo_police_galivan.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_cosmo_police_galivan.snap new file mode 100644 index 00000000..77023835 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_cosmo_police_galivan.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].zip +rom_sha256=089e5314970fb3e00e4d572b52686804358958a20d6458f6d8555e48e08a8c24 +frames=2600 +fb_bytes=245760 +cycles=77399530 +audio_samples=1907107 +audio_fnv1a64=a11384b863298ca7 +checkpoint f2600 fb_fnv1a64=865fcaa1d9e8c5cb diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_ganbare_goemon.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_ganbare_goemon.snap new file mode 100644 index 00000000..982f22ae --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_ganbare_goemon.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].zip +rom_sha256=5ad056d725c64c330ea89c138271434a6869403dd47a99451352ad88428b9de8 +frames=2600 +fb_bytes=245760 +cycles=77399552 +audio_samples=1907107 +audio_fnv1a64=528ae7bcc42b599d +checkpoint f2600 fb_fnv1a64=f70ba6ebf3b82a3e diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_wai_wai_world.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_wai_wai_world.snap new file mode 100644 index 00000000..06c4ed3a --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m006_wai_wai_world.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].zip +rom_sha256=64b80dc565962812d842ef7181b7f2cbe8da1519b4d3abfcb11e11761b636a81 +frames=3800 +fb_bytes=245760 +cycles=113136135 +audio_samples=2787656 +audio_fnv1a64=c9a9e85be51a8ba4 +checkpoint f3800 fb_fnv1a64=e67690e53bef92bf diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m008_mermaids_of_atlantis.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m008_mermaids_of_atlantis.snap new file mode 100644 index 00000000..9cb9bffa --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m008_mermaids_of_atlantis.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-008-FFE-GNROM/Mermaids of Atlantis - The Riddle of the Magic Bubble (AVE) [T+Rus_Guyver].zip +rom_sha256=018fcac8414972399cb41aeb9ba0c93451a5a067d1216bd892e668f416d449cb +frames=2600 +fb_bytes=245760 +cycles=77399524 +audio_samples=1907106 +audio_fnv1a64=5e232943a1371da3 +checkpoint f2600 fb_fnv1a64=f83da8cb4f28f8ac diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_dragon_ball_z_iii.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_dragon_ball_z_iii.snap new file mode 100644 index 00000000..3aa2a5ae --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_dragon_ball_z_iii.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].zip +rom_sha256=424a49d3a8dd1800ed5710c4371fdc4bdf2d217c3f20276f188e4d7bdef6048f +frames=2600 +fb_bytes=245760 +cycles=77399540 +audio_samples=1907107 +audio_fnv1a64=621bceb66d690c23 +checkpoint f2600 fb_fnv1a64=b1480598cbe4ecab diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_juvei_quest.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_juvei_quest.snap new file mode 100644 index 00000000..fe9c2ab5 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_juvei_quest.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-017-FFE-SuperMagicCard/Juvei Quest (J) [hFFE].zip +rom_sha256=755d352e081aa456229f56c39b8eb5ccbd013fb0b13f9d775e0687b2cc3bf745 +frames=2600 +fb_bytes=245760 +cycles=77399522 +audio_samples=1907106 +audio_fnv1a64=06bfa8d2a961e5f8 +checkpoint f2600 fb_fnv1a64=bd88bd5c1d2cb9a9 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_kaiketsu_yanchamaru_2.snap b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_kaiketsu_yanchamaru_2.snap new file mode 100644 index 00000000..1ffc1526 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_extended__extended_m017_kaiketsu_yanchamaru_2.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_extended.rs +expression: text +--- +rom=mapper-017-FFE-SuperMagicCard/Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE].zip +rom_sha256=d2b410a12d2f2f5b2cffeca5d84b8b1fb8d720f69e76e8c65208da70c4c546a9 +frames=2600 +fb_bytes=245760 +cycles=77399528 +audio_samples=1907107 +audio_fnv1a64=e586393c01987e8f +checkpoint f2600 fb_fnv1a64=02f9ee19989ccca1 diff --git a/docs/compatibility.md b/docs/compatibility.md index fe52da41..1df06ba3 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -381,3 +381,10 @@ in `crates/rustynes-test-harness/tests/external_extended.rs`. | 230 | 22-in-1 (Reset Based) [p1] (Contra at power-on) | `cf3372f6f5b22b06c7382e48426033e3025310f8e4885fe001d3ed5d31a4da10` | 600 | `screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Contra at power-on.png` | | 230 | 22-in-1 (Reset Based) [p1] (multicart after RESET) | `cf3372f6f5b22b06c7382e48426033e3025310f8e4885fe001d3ed5d31a4da10` | 3500 (RESET at 700, DOWN x2, A, START; Sky Destroyer in flight) | `screenshots/external/mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1] - Sky Destroyer after reset.png` | | 235 | `260-in-1 [p1][!]` (staged with a NES 2.0 header; body unchanged) | `0c62f12da81c712a8a9d56e808ff8825d824169cfff05eee2a15c7456f3ceb91` | 1600 (RIGHT, A, START; Mr. Mario I phase 1) | `screenshots/external/mapper-235-GoldenGame/260-in-1 [p1][!] - Mr. Mario I.png` | +| 6 | Ganbare Goemon! - Karakuri Douchuu (J) [hFFE] (trainer) | `d390d04843f7ea5b72203ac001956df1fd57ec62febccdba1bba43f88377c064` | 2600 (START/START/A/START; in play) | `screenshots/external/mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].png` | +| 6 | Cosmo Police Galivan (J) `[hFFE][p1]` | `4d90a01e16203511877166c804282316cac65105d687724fe6d3714f1e4dda9c` | 2600 (same script; status screen of area 1) | `screenshots/external/mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].png` | +| 6 | Wai Wai World (J) [hFFE] (trainer) | `eccd72962bb3da25541dc818757d71afcd9b9b5530326597187d30a9fca61f25` | 3800 (menu answered, RIGHT held; first stage in play) | `screenshots/external/mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].png` | +| 8 | Mermaids of Atlantis (AVE) `[T+Rus_Guyver]` | `909158c6d116ffcff01ac7fb616bc2f46d48a4230af970ffff95631ca0ee5bbb` | 2600 (START/START/A/START; in play, paused) | `screenshots/external/mapper-008-FFE-GNROM/Mermaids of Atlantis - The Riddle of the Magic Bubble (AVE) [T+Rus_Guyver].png` | +| 17 | Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE] | `1ea190b0e2ac856d6caeca343812d3553621c5120689224e1650463eb8cf764b` | 2600 (START/START/A/START; in play) | `screenshots/external/mapper-017-FFE-SuperMagicCard/Kaiketsu Yanchamaru 2 - Karakuri Land (J) [hFFE].png` | +| 17 | Juvei Quest (J) [hFFE] (trainer) | `1915b1b02ae9fbd27962065b7482c744ccc5f938198b4fefbed71588b1207e9c` | 2600 (same script; name entry) | `screenshots/external/mapper-017-FFE-SuperMagicCard/Juvei Quest (J) [hFFE].png` | +| 17 | Dragon Ball Z III - Ressen Jinzou Ningen (J) `[hFFE][p1]` | `1d5e6d3a998d3828659da2cfe4bbba1c664dad6a337314cf65c4125afdf473e6` | 2600 (same script; story text box) | `screenshots/external/mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].png` | diff --git a/docs/mappers.md b/docs/mappers.md index 751c8db4..03b37f44 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -854,6 +854,9 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 158 | — | Tengen 800037 (`INES_Mapper_158`, `RAMBO_1` Variants) | Curated | RAMBO-1 (A12 / CPU) | `Rambo1::new_m158` in `m064_rambo1.rs`: the RAMBO-1 with CHR A17 on CIRAM A10, as TLSROM is to MMC3. Nametable *n* takes bit 7 of the 1 KiB bank in slot *n* of `$0000-$0FFF`, after the C and K bits (R0/R1 in 2 KiB mode, R0/R8/R1/R9 with K, R2-R5 under C). `$A000` is not connected. Mapper 64's path is unchanged. The NES 2.0 *Alien Syndrome (USA) (Unl)* is the dump; the GoodNES `(Tengen) [!]` image has the same body headered as 118, and the game database lists that CRC as 118 (its NES 2.0 guard leaves the 158 header alone). | | 207 | — | Taito X1-005, CHR A17 mirroring (`INES_Mapper_207`) | Curated | — | `TaitoX1005::new_m207` in `m080_taito_x1_005.rs`: the X1-005 with CHR A17 on CIRAM A10, so bit 7 of `$7EF0` pages nametables `$2000`/`$2400` and bit 7 of `$7EF1` pages `$2800`/`$2C00` (1scA / 1scB / H). `$7EF6-$7EF7` are ignored. Everything else is mapper 80's, whose path is unchanged. Evidence is the English translation, which carries a 207 header; the original Japanese GoodNES `[!]` is headered 80, and the game database lists its CRC as 80 too, so it does not reach this board. The boot snapshot plays into stage 1 (`Held`: the menu ignores one-frame taps). | | 252 | — | Waixing VRC4 clone (`INES_Mapper_252`) | Curated | VRC4 (scanline / CPU) | `m252_waixing_vrc4.rs`: the project's `Vrc4` wired as mapper 23 submapper 2 (VRC4e: chip A0 = CPU A2, A1 = CPU A3) with its 8 KiB PRG-RAM and IRQ, plus 2 KiB of CHR-RAM: a 1 KiB slot whose bank register holds exactly 6 or 7 reads and writes RAM instead of CHR-ROM. The page names only the page numbers; a whole-value match is the plain reading, and *San Guo Zhi* rules out a 3-bit match (it would turn the portraits' ROM pages 14 and 23 into RAM). Booting it found the VRC4 IRQ mode-bit inversion described below. Mapper 253's older model in `waixing.rs` is not reused. | +| 6 | 0-7 | FFE Magic Card 1M/2M extraction (`INES_Mapper_006`, `Super_Magic_Card`) | Curated | card counter (M2 / PA12), FDS adapter timer | `ffe_super_magic_card.rs`: a model of the Super Magic Card in Play mode (512 KiB PRG DRAM, 256 KiB CHR-RAM, 32 KiB WRAM, 4 KiB scratch RAM; the image loaded at the start of each). The submapper is the initial latch mode (iNES 1.0 = 1, UN1ROM+CHRSW); all eight latch modes, the 2M and 4M modes, 1 KiB CHR, CHR-RAM nametables, MMC4 mode, the `$42Fx` write-enable, the card's 16-bit up-counter and the FDS adapter's 149 1/3-cycle disk IRQ (`$4025` bit 7, `$4024` acknowledge) are modelled. The trainer loads at `$7000`, and a power-on reset runs `JSR $7003` then the game's vector through a six-byte overlay at `$7200`. GUI and pass-through modes are not modelled. The mirroring-type encoding `A0 << 1 \| D4` is the only one consistent with the page's two initial values. *Contra (J) [hFFE]* draws stage 1 with a black background under its header's horizontal mirroring and correctly under vertical: the game never writes `$42Fx`, so the header decides, and this dump's header bit looks wrong; it is not staged. *Kaiketsu Yanchamaru (J) [hFFE]* (no trainer) boots to a grey screen: its reset vector in every PRG page points at data, so it needs a set-up the dump does not carry; not staged. | +| 8 | — | FFE GNROM-mode extraction (`INES_Mapper_008`) | Curated | as 6 | "A synonym of Mapper 6 submapper 4": the same card in latch mode 4 (32 KiB PRG `D5-D4`, 8 KiB CHR `D1-D0`, CHR write-protected). The two *Doraemon World 3* hack images in `mapper-072-Jaleco72/`, whose masked dirty header is mapper 8, now boot and leave `external_coverage`'s `UNSUPPORTED` list. | +| 17 | 0-3 | FFE Super Magic Card extraction (`INES_Mapper_017`) | Curated | as 6 | The same card starting in 4M mode with `$4504-$4507` on the image's last four 8 KiB banks and 1 KiB CHR enabled (`$4500 = $47`). The submapper is the trainer's load address (`$7000`, `$5D00`, `$5E00`, `$5F00`), and a power-on reset jumps to it instead of the vector. `$4510-$4517` start at 0-7 (the page gives no value). | | 235 | — | "Golden Game" 150-in-1 / 260-in-1 (`INES_Mapper_235`) | Curated | — | `m235_golden_game.rs`. Address latch `1xMP RNBB xxxA AAAA`: 32 KiB page A within the 1 MiB socket B; R = 1 maps half P of it twice; M picks CIRAM A10 from PPU A11 / A10 (H / V) and N forces page A (1scA), per the page's 74'153 description. RESET and power-on clear the register ("All eleven bits ... cleared on reset"). Sockets: a 4 MiB image fills all four; a 2 MiB image is sockets 0 and 2 ("00b = ROM 0, 10b = ROM 1"); 1 MiB or less is socket 0; an empty socket is open bus. The GoodNES `260-in-1 [p1][!]` header has iNES byte 4 = 0 for its 4 MiB, which iNES cannot express, so `parse` reads no PRG; it is staged with a NES 2.0 header (PRG MSB 1) and its body unchanged. | | 230 | — | 22-in-1, reset-switched (`INES_Mapper_230`, Disch's notes) | Curated | — | `m230_reset_multicart.rs`. Chip 0 (first 128 KiB, *Contra*): `[.... .PPP]` 16 KiB at `$8000`, page 7 fixed at `$C000`, always vertical. Chip 1 (next 512 KiB): `[.MOP PPPP]`, O = 0 one 32 KiB window at P >> 1, O = 1 page P twice, M = 0 horizontal. The console RESET (`Mapper::reset`) toggles the chip and clears the register. The notes leave the power-on mode and the register across a toggle open: this board powers on in *Contra* and clears the register, and the dump behaves that way (title at power-on, menu after RESET). Curated on the brief's D23 rule although the page is Disch's notes only; the integrator may prefer the v2.9.6 rule that makes notes-only pages BestEffort. | diff --git a/screenshots/external/mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].png b/screenshots/external/mapper-006-FFE-MagicCard/Cosmo Police Galivan (J) [hFFE][p1].png new file mode 100644 index 0000000000000000000000000000000000000000..dc76f32f3654cdd2a125770997e76eda475a9b05 GIT binary patch literal 3280 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].png b/screenshots/external/mapper-006-FFE-MagicCard/Ganbare Goemon! - Karakuri Douchuu (J) [hFFE].png new file mode 100644 index 0000000000000000000000000000000000000000..3ba5de8550479ad32d96ca301725a458e91c5a55 GIT binary patch literal 4984 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].png b/screenshots/external/mapper-006-FFE-MagicCard/Wai Wai World (J) [hFFE].png new file mode 100644 index 0000000000000000000000000000000000000000..704faccf3d247d43560633611536e2349af7b66f GIT binary patch literal 4915 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

jwPaiE>JjI@Tbg9##4UEoiIwVE@uD&gK-SBsc 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].png b/screenshots/external/mapper-017-FFE-SuperMagicCard/Dragon Ball Z III - Ressen Jinzou Ningen (J) [hFFE][p1].png new file mode 100644 index 0000000000000000000000000000000000000000..a0d663e488469a69f44004119dee99ab5557c120 GIT binary patch literal 5985 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 Date: Fri, 9 Oct 2026 08:35:39 -0400 Subject: [PATCH 029/104] docs(adr): amend ADR 0011 with D23 and the notes-only tier ruling Records the two v3.2.0 refinements to what makes a mapper family Curated, so the rule lives in the ADR and not only in tier.rs comments. D23: a family with no redistributable fixture may be Curated on a staged local dump that boots to a correct picture, recorded in docs/compatibility.md by title, Nes::rom_sha256, frames and a committed screenshot. Notes-only pages (maintainer's ruling, 2026-10-09). v2.9.6 capped a page of only Disch's notes, or of "probably" masks, at BestEffort. Such a family is now Curated on a D23 record when every behaviour the page leaves unstated was either not needed or settled by the dump's boot visibly rejecting the alternative; it stays BestEffort when the model chose an unstated behaviour no boot tested against an alternative. Correction, stated plainly: the question put to the maintainer described 165 as needing "no unstated choices". That was wrong. Its model makes three (the MMC2's exact latch triggers rather than the notes' $xFD0 span, 8 KiB of work RAM, a CHR page of the register value over 4), and each was settled by screens: the alternatives drew a stray bar before every intro line or a garbage Fire Emblem Gaiden title. Put to the maintainer again with that stated, the ruling is that a choice the boot visibly discriminates is acceptable. So 47, 121, 191 and 165 are Curated and 230 is BestEffort, whose power-on mode and reset behaviour were chosen and never tested against an alternative. The tier.rs comment (which repeated the misdescription) and the docs/compatibility.md note are aligned with the amendment; no tier changes from 2385f5cf (51 / 130 / 27). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/tier.rs | 12 +++++++----- docs/adr/0011-mapper-tiering.md | 30 +++++++++++++++++++++++++++++ docs/compatibility.md | 3 ++- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs index ae5a5654..859de778 100644 --- a/crates/rustynes-mappers/src/tier.rs +++ b/crates/rustynes-mappers/src/tier.rs @@ -156,11 +156,13 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option { // The v2.9.6 rule capped a page of only Disch's notes, or of masks // marked "probably", at BestEffort. The maintainer's ruling for // v3.2.0 (2026-10-09): such a family is Curated on a D23 record when - // its model needed NO behaviour the page leaves unstated, the correct - // boot then confirming what the notes give (47, 121, 191, and 165 - // below). Where the model had to choose an unstated behaviour, the - // boot cannot tell a right choice from a lucky one, and the family - // stays BestEffort (230, below). + // every behaviour its page leaves unstated was either not needed or + // settled by the dump's boot visibly rejecting the alternative (47, + // 121, 191; 165 below, whose three choices the screens settled). + // Where the model chose an unstated behaviour no boot tested against + // an alternative, a correct boot cannot tell a right choice from a + // lucky one, and the family stays BestEffort (230, below). ADR 0011's + // v3.2.0 amendment. | 47 | 121 | 154 | 191 | 243 => { Some(MapperTier::Curated) } diff --git a/docs/adr/0011-mapper-tiering.md b/docs/adr/0011-mapper-tiering.md index 0c8f72ad..4ad1ac76 100644 --- a/docs/adr/0011-mapper-tiering.md +++ b/docs/adr/0011-mapper-tiering.md @@ -117,3 +117,33 @@ counts). unclassified mapper (it reports `BestEffort` register-decode verification dumps, which are permitted in the tree but never wired to a byte-identity oracle). + +## Amendment (v3.2.0, 2026-10-09): local-dump evidence and notes-only pages + +Two rulings refine the evidence a family needs to be Curated. Neither +changes the invariant above, which `mapper_tier_honesty.rs` still enforces. + +- **D23 (local dumps).** A family with no redistributable fixture may be + Curated when a staged local dump boots to a correct picture and + `docs/compatibility.md` ("Curated evidence from local dumps") records its + title, the SHA-256 of its bytes after the header (`Nes::rom_sha256`), the + frames the capture ran and a committed screenshot. The dump itself stays + gitignored. +- **Notes-only pages (maintainer, 2026-10-09).** v2.9.6 capped a family whose + NESdev page gives only Disch's notes, or register masks marked "probably", + at BestEffort. It is now Curated on a D23 record when every behaviour the + page leaves unstated was either not needed by the model or **settled by the + dump's boot visibly rejecting the alternative**. It stays BestEffort when + the model chose an unstated behaviour that no boot tested against an + alternative, because then a correct boot cannot tell a right choice from a + lucky one. Under this rule 47, 121 and 191 are Curated, and so is 165: its + three choices (the MMC2's exact latch triggers, 8 KiB of work RAM, a CHR page + of the register value over 4) were each settled by screens, the alternatives + drawing a stray bar before every intro line or a garbage title. 230 is + BestEffort: its notes do not say which mode the board powers on in or what a + console reset does to its register, and the model's choices were never + tested against an alternative. A page, a hardware report or a test ROM + that states the missing behaviour is what promotes such a family. + +A BestEffort family's boots are pinned by `external_coverage`, never by the +byte-identity oracle files; 230's run moved there for that reason. diff --git a/docs/compatibility.md b/docs/compatibility.md index 61130281..e6c5fd16 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -440,7 +440,8 @@ became is in `docs/mappers.md`, mapper 157). byte-identity oracle files), with screenshots under `screenshots/besteffort/`, and a page or a hardware report that states the two behaviours would make it Curated. The notes-only families whose models needed - no unstated behaviour (47, 121, 191, 165) are Curated on the rows above. + no unstated behaviour, or whose choices a boot visibly settled (47, 121, 191, + 165), are Curated on the rows above (ADR 0011, v3.2.0 amendment). - **The Sachen titles GoodNES headers as mapper 243** (*Strategist*, *Poker III*, *Chinese Checkers*, *Magic Cubes* and others) are SA-015 games, mapper 150 (`nesdev_wiki/INES_Mapper_150.xhtml` lists them; 243's page names *Honey From bb66f0afeed629de7306709b2891f0fca63c7975 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 21:02:41 -0400 Subject: [PATCH 030/104] chore: import the agy/Codex audit-remediation code from codex_sec, as is This commit brings in, unchanged, the code another set of AI agents (Antigravity "agy", Codex and "DeepCoder" subagents) wrote on the local, never-pushed branch codex_sec (= agy/perf_impr, 4ddeddf9..48c48865, 2026-10-09/10) on top of feat/v3.2.0 c922ed40. It is the starting point for a reviewed adoption, NOT an endorsement: the commits that follow fix, revert or finish each problem the 2026-10-13 audits found, one per commit, so every correction is separately bisectable against this import. What is imported: 156 modified files (+2,601 / -403) across the APU, PPU, core bus, ~150 mappers (a new Mapper::ppu_peek on every board), the game database, netplay, the desktop frontend, libretro, mobile/Android/iOS, the Lua sandbox, cheevos, cosim and the WGSL shaders. The source was two AI-written audits (Round 1, whose document exists nowhere, and docs/Round2-Audit.md, 61 findings). What is deliberately NOT imported: - salvaged_tmp/ (21 files, 8.5 MB), session.main.rs and diff.txt: agent scratch committed by bypassing the hooks. It holds personal data (the author email, private repository names, an agent transcript, a /tmp listing) and PR-automation scripts for other repositories. Saved to the gitignored salvaged/agy-perf-impr-2026-10-10/, never to the tree. - docs/Claude_Handoff.md: its "ALL 54 patches", "all tests pass" and "determinism is maintained" claims are contradicted by the diff; its useful history goes into the Round 2 disposition ledger instead. - docs/Round2-Audit.md: lands separately under docs/audits/ with a header marking its provenance and "certified" claims as unverified AI self-attestation, beside a disposition ledger. - The deletions of docs/SALVAGE_MANIFEST.md and docs/audits/README.md, which were collateral (both are still linked). Known problems in this import, each fixed by a later commit (from the 2026-10-13 audits; line numbers as on codex_sec): - emulation output changes presented as performance work: the MMC5 scanline detector in ppu_read (the IRQ never fires through the real bus), the Namco 163 output cache (sums all 8 channels; goes stale), the open-bus shortcut at bus.rs:3805 (mappers 228/235 lose open bus at $8000+), the FDS output formula (rounding), the even-dot secondary-OAM clear, MMC3's % total on non-power-of-two PRG/CHR, GxROM load_state not refreshing its cached offsets; - the NET-01 leak fix undone by 7913b224; InputBatch sent but never decoded; source filters that break NAT rebinding; the try_lock netplay threading change; Android float-track audio written as bytes (silent); mobile FDS saves (iOS writes one shared file); the wasm cheevos rewrite; build breaks only off the host default build (wasm getrandom, iOS NES_W/NES_H, piccolo ctx.metrics()). Verified at this commit: cargo check --workspace --all-targets, cargo fmt --all --check and workspace clippy -D warnings are clean on the host default build. Nothing else is claimed for it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- Cargo.lock | 2 + Cargo.toml | 1 - .../com/doublegate/rustynes/MainActivity.kt | 88 +++++-- .../com/doublegate/rustynes/NesSurfaceView.kt | 1 + crates/rustynes-apu/src/apu.rs | 6 +- crates/rustynes-apu/src/blip.rs | 38 ++- crates/rustynes-apu/src/mixer.rs | 38 ++- crates/rustynes-apu/src/noise.rs | 2 +- crates/rustynes-apu/src/pulse.rs | 8 +- crates/rustynes-cheevos/src/client.rs | 14 +- crates/rustynes-cheevos/src/ffi.rs | 5 +- crates/rustynes-cheevos/src/http.rs | 17 +- crates/rustynes-core/src/bus.rs | 21 +- crates/rustynes-core/src/nes.rs | 10 + crates/rustynes-cosim/src/lib.rs | 29 ++- crates/rustynes-frontend/src/app.rs | 6 +- crates/rustynes-frontend/src/atomic_write.rs | 4 +- crates/rustynes-frontend/src/audio.rs | 33 ++- crates/rustynes-frontend/src/audio_dsp.rs | 2 +- .../rustynes-frontend/src/battery_policy.rs | 26 +- crates/rustynes-frontend/src/battery_save.rs | 4 +- crates/rustynes-frontend/src/emu.rs | 6 + crates/rustynes-frontend/src/emu_thread.rs | 5 +- crates/rustynes-frontend/src/netplay_ui.rs | 20 ++ .../rustynes-frontend/src/present_buffer.rs | 6 +- crates/rustynes-frontend/src/wasm_cheevos.rs | 22 +- crates/rustynes-frontend/src/web_battery.rs | 2 +- crates/rustynes-gamedb/src/lib.rs | 56 ++-- .../rustynes-gfx-shaders/src/crt_guest.wgsl | 12 +- .../rustynes-gfx-shaders/src/crt_royale.wgsl | 2 +- crates/rustynes-gfx-shaders/src/lib.rs | 4 +- crates/rustynes-gfx-shaders/src/megatron.wgsl | 2 +- .../src/signal_decode.wgsl | 16 +- crates/rustynes-ios/src/ffi.rs | 9 +- crates/rustynes-ios/src/gfx_metal.rs | 15 +- crates/rustynes-libretro/src/lib.rs | 43 +-- crates/rustynes-mappers/src/bmc_simple.rs | 21 ++ crates/rustynes-mappers/src/fds.rs | 27 +- .../src/ffe_super_magic_card.rs | 9 + .../rustynes-mappers/src/homebrew_boards.rs | 86 +++++- .../rustynes-mappers/src/jaleco_discrete.rs | 47 ++++ crates/rustynes-mappers/src/kaiser.rs | 20 ++ crates/rustynes-mappers/src/lib.rs | 32 ++- crates/rustynes-mappers/src/m000_nrom.rs | 15 ++ crates/rustynes-mappers/src/m001_mmc1.rs | 15 ++ crates/rustynes-mappers/src/m002_uxrom.rs | 31 ++- crates/rustynes-mappers/src/m003_cnrom.rs | 29 ++- crates/rustynes-mappers/src/m004_mmc3.rs | 100 ++++--- crates/rustynes-mappers/src/m005_mmc5.rs | 77 +++++- crates/rustynes-mappers/src/m007_axrom.rs | 22 +- crates/rustynes-mappers/src/m009_mmc2.rs | 14 +- crates/rustynes-mappers/src/m010_mmc4.rs | 14 +- .../rustynes-mappers/src/m011_color_dreams.rs | 13 + crates/rustynes-mappers/src/m013_cprom.rs | 14 + .../rustynes-mappers/src/m016_bandai_fcg.rs | 12 + .../src/m018_jaleco_ss88006.rs | 12 + crates/rustynes-mappers/src/m019_namco163.rs | 49 +++- crates/rustynes-mappers/src/m021_vrc4.rs | 12 + crates/rustynes-mappers/src/m022_vrc2.rs | 25 +- crates/rustynes-mappers/src/m024_vrc6.rs | 12 + crates/rustynes-mappers/src/m032_irem_g101.rs | 9 + .../rustynes-mappers/src/m033_taito_tc0190.rs | 9 + .../src/m034_bnrom_nina001.rs | 21 ++ crates/rustynes-mappers/src/m035_jy_asic.rs | 10 + .../src/m036_txc_policeman.rs | 13 + crates/rustynes-mappers/src/m038_bitcorp38.rs | 13 + crates/rustynes-mappers/src/m039_subor39.rs | 10 + crates/rustynes-mappers/src/m041_caltron41.rs | 9 + .../src/m042_fds_conv_bio_miracle.rs | 16 ++ .../rustynes-mappers/src/m048_taito_tc0690.rs | 9 + .../src/m050_fds_conv_smb2j.rs | 9 + crates/rustynes-mappers/src/m064_rambo1.rs | 12 + .../rustynes-mappers/src/m065_irem_h3001.rs | 12 + crates/rustynes-mappers/src/m066_gxrom.rs | 37 ++- crates/rustynes-mappers/src/m067_sunsoft3.rs | 12 + crates/rustynes-mappers/src/m068_sunsoft4.rs | 21 ++ .../rustynes-mappers/src/m069_sunsoft_fme7.rs | 48 ++-- crates/rustynes-mappers/src/m070_bandai74.rs | 9 + .../src/m071_camerica_bf9093.rs | 12 +- crates/rustynes-mappers/src/m073_vrc3.rs | 9 + crates/rustynes-mappers/src/m075_vrc1.rs | 18 ++ .../rustynes-mappers/src/m076_namcot3446.rs | 14 + .../src/m077_irem_napoleon.rs | 15 ++ .../src/m078_irem_jaleco78.rs | 12 + .../src/m079_ave_nina03_06.rs | 9 + .../rustynes-mappers/src/m080_taito_x1_005.rs | 9 + .../rustynes-mappers/src/m082_taito_x1_017.rs | 9 + crates/rustynes-mappers/src/m083_cony.rs | 9 + crates/rustynes-mappers/src/m085_vrc7.rs | 12 + crates/rustynes-mappers/src/m087_jaleco87.rs | 13 + crates/rustynes-mappers/src/m088_namco118.rs | 12 + crates/rustynes-mappers/src/m089_sunsoft2.rs | 9 + crates/rustynes-mappers/src/m091_jy_sf3.rs | 9 + crates/rustynes-mappers/src/m093_sunsoft3r.rs | 16 ++ crates/rustynes-mappers/src/m094_un1rom.rs | 9 + .../rustynes-mappers/src/m095_namcot3425.rs | 9 + crates/rustynes-mappers/src/m096_bandai96.rs | 9 + .../rustynes-mappers/src/m097_irem_tam_s1.rs | 9 + crates/rustynes-mappers/src/m099_vs_system.rs | 9 + crates/rustynes-mappers/src/m105_nes_event.rs | 9 + .../src/m107_magic_dragon107.rs | 13 + .../rustynes-mappers/src/m113_ave_nina006.rs | 9 + crates/rustynes-mappers/src/m116_somari.rs | 9 + crates/rustynes-mappers/src/m118_txsrom.rs | 4 + crates/rustynes-mappers/src/m119_tqrom.rs | 14 + crates/rustynes-mappers/src/m132_txc_22211.rs | 13 + .../rustynes-mappers/src/m136_sachen_3011.rs | 12 + crates/rustynes-mappers/src/m151_konami_vs.rs | 4 + crates/rustynes-mappers/src/m152_bandai152.rs | 9 + crates/rustynes-mappers/src/m156_daou156.rs | 9 + crates/rustynes-mappers/src/m163_nanjing.rs | 9 + crates/rustynes-mappers/src/m172_m173_txc.rs | 18 ++ crates/rustynes-mappers/src/m176_bmc_fk23c.rs | 25 ++ .../rustynes-mappers/src/m177_hengedianzi.rs | 9 + .../rustynes-mappers/src/m179_hengedianzi.rs | 9 + .../src/m180_nichibutsu180.rs | 9 + crates/rustynes-mappers/src/m184_sunsoft1.rs | 9 + crates/rustynes-mappers/src/m185_cnrom185.rs | 19 ++ crates/rustynes-mappers/src/m210_namco175.rs | 12 + crates/rustynes-mappers/src/m228_action52.rs | 9 + .../src/m230_reset_multicart.rs | 9 + .../src/m232_camerica_bf9096.rs | 9 + .../rustynes-mappers/src/m235_golden_game.rs | 9 + .../src/m240_cne_multicart.rs | 13 + crates/rustynes-mappers/src/m241_bxrom241.rs | 9 + .../src/m244_cne_decathlon.rs | 13 + .../src/m246_fong_shen_bang246.rs | 14 + crates/rustynes-mappers/src/m250_nitra250.rs | 9 + .../rustynes-mappers/src/m252_waixing_vrc4.rs | 10 + .../rustynes-mappers/src/m268_bmc_coolboy.rs | 16 ++ .../rustynes-mappers/src/m513_sachen_9602.rs | 9 + crates/rustynes-mappers/src/mapper.rs | 15 +- crates/rustynes-mappers/src/mmc3_boards.rs | 9 + .../rustynes-mappers/src/mmc3_boards_ext.rs | 11 + crates/rustynes-mappers/src/mmc3_clones.rs | 21 ++ .../src/multicart_discrete.rs | 245 ++++++++++++++++++ crates/rustynes-mappers/src/nsf.rs | 5 + crates/rustynes-mappers/src/ntdec.rs | 79 ++++++ crates/rustynes-mappers/src/sachen_8259.rs | 26 ++ .../rustynes-mappers/src/sachen_discrete.rs | 92 +++++++ crates/rustynes-mappers/src/waixing.rs | 47 ++++ crates/rustynes-mobile/Cargo.toml | 1 + crates/rustynes-mobile/src/lib.rs | 12 +- crates/rustynes-netplay/Cargo.toml | 1 + crates/rustynes-netplay/src/connection.rs | 4 +- crates/rustynes-netplay/src/mesh_net.rs | 46 +++- crates/rustynes-netplay/src/message.rs | 23 ++ crates/rustynes-netplay/src/relay.rs | 10 +- crates/rustynes-netplay/src/session.rs | 94 ++++--- crates/rustynes-netplay/src/spectator.rs | 3 +- crates/rustynes-ppu/src/ppu.rs | 48 ++-- crates/rustynes-ppu/src/snapshot.rs | 1 + crates/rustynes-script/src/piccolo_backend.rs | 14 +- .../tests/snapshot_schema_audit.rs | 4 + ios/RustyNES/AppModel.swift | 42 ++- ios/RustyNES/RomIdentity.swift | 4 + 156 files changed, 2601 insertions(+), 403 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94c71eed..d6b8367a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4477,6 +4477,7 @@ dependencies = [ name = "rustynes-mobile" version = "3.1.0" dependencies = [ + "getrandom 0.2.17", "rustynes-core", "rustynes-gamedb", "rustynes-hdpack", @@ -4494,6 +4495,7 @@ name = "rustynes-netplay" version = "3.1.0" dependencies = [ "futures-util", + "getrandom 0.2.17", "js-sys", "rustynes-core", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 00eec722..00e10ebf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,7 +99,6 @@ rustynes-apu = { path = "crates/rustynes-apu", version = "3.0.0", default-featur rustynes-mappers = { path = "crates/rustynes-mappers", version = "3.0.0" } rustynes-netplay = { path = "crates/rustynes-netplay", version = "3.0.0" } rustynes-script = { path = "crates/rustynes-script", version = "3.0.0" } -rustynes-test-harness = { path = "crates/rustynes-test-harness", version = "3.0.0" } # v1.8.0 "Android" — the shared mobile bridge, consumed by rustynes-android (and # v1.9.0's rustynes-ios). Path dep; the version req is a floor. rustynes-mobile = { path = "crates/rustynes-mobile", version = "3.0.0" } diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt index f687261a..9bb71ccd 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt @@ -307,6 +307,16 @@ class MainActivity : AppCompatActivity() { } } + fun updateScreenWakeLock(running: Boolean) { + runOnUiThread { + if (running) { + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + override fun onPause() { super.onPause() // Stop listening for controller hot-plug while backgrounded. @@ -450,7 +460,7 @@ class MainActivity : AppCompatActivity() { private fun onPauseSaveState() { // v2.7.4 (AND-09): the in-game battery save, synchronously -- the process // may be killed at any point once it is in the background. - emulator.flushBattery() + emulator.flushBattery(this) val ctrl = emulator.controller val sha = emulator.romSha // RetroAchievements progress sidecar (v1.8.6). A no-op when no RA session / @@ -558,11 +568,21 @@ class EmulatorHandle { * land before the controller goes away. A failure is logged and retried by * the next flush; the file keeps its previous contents. */ - fun flushBattery() { - val saver = battery ?: return + fun flushBattery(context: Context) { val ctrl = controller ?: return - runCatching { saver.flushIfChanged { ctrl.batteryRam() } } - .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + battery?.let { saver -> + runCatching { saver.flushIfChanged { ctrl.batteryRam() } } + .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + } + val sha = romSha + if (sha != null && ctrl.diskIsDirty()) { + runCatching { + val fdsDir = java.io.File(context.filesDir, "fds") + fdsDir.mkdirs() + java.io.File(fdsDir, "disk_$sha.fds").writeBytes(ctrl.diskImageBytes()) + ctrl.clearDiskDirty() + }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } + } } /** Lowercase hex SHA-256 of the loaded ROM — the save-state directory key. */ @@ -753,13 +773,22 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri // v2.9.7 "Tandem": the stored FDS BIOS (if the user chose one) rides along, so an // .fds disk boots; a cartridge or NSF ignores it. Without it a disk throws // MobileException.MissingFdsBios, which openRom turns into the BIOS prompt. - val ctrl = NesController.newWithFdsBios(bytes, FdsBios.load(context), 48_000u) + var ctrl = NesController.newWithFdsBios(bytes, FdsBios.load(context), 48_000u) // v2.9.9 (re-audit NF-21): key every per-game store by the core's ROM identity // (the bytes after the iNES header, of the unpacked image), not the file as read. // Before any store is touched, move whatever the pre-v2.9.9 whole-file key held // (once; never over an existing store; see RomKeyMigration). val sha = ctrl.romIdentity() RomKeyMigration.migrate(context.filesDir, sha256Hex(bytes), sha) + val fdsFile = java.io.File(java.io.File(context.filesDir, "fds"), "disk_$sha.fds") + if (fdsFile.exists()) { + runCatching { + val diskBytes = fdsFile.readBytes() + if (diskBytes.size == bytes.size) { + ctrl = NesController.newWithFdsBios(diskBytes, FdsBios.load(context), 48_000u) + } + } + } // Load the cartridge's `.sav` before its first frame, and before the auto-resume // state below (a save state is newer and restores cartridge RAM too). val (battery, batteryNotice) = attachBattery(context, ctrl, sha) @@ -804,6 +833,7 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri * and never blocks the UI thread. */ private fun publishRom( + context: Context, scope: kotlinx.coroutines.CoroutineScope, emulator: EmulatorHandle, prepared: PreparedRom, @@ -811,14 +841,25 @@ private fun publishRom( ): String { val outgoingCtrl = emulator.controller val outgoingBattery = emulator.battery + val outgoingSha = emulator.romSha emulator.controller = prepared.ctrl emulator.romSha = prepared.sha emulator.romBytes = prepared.bytes emulator.battery = prepared.battery - if (outgoingCtrl != null && outgoingBattery != null) { + if (outgoingCtrl != null) { scope.launch(Dispatchers.IO) { - runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } - .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + if (outgoingBattery != null) { + runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } + .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + } + if (outgoingSha != null && outgoingCtrl.diskIsDirty()) { + runCatching { + val fdsDir = java.io.File(context.filesDir, "fds") + fdsDir.mkdirs() + java.io.File(fdsDir, "disk_$outgoingSha.fds").writeBytes(outgoingCtrl.diskImageBytes()) + outgoingCtrl.clearDiskDirty() + }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } + } } } // Apply this game's remembered video filter (per-game DB), if any. @@ -1015,13 +1056,10 @@ private class AudioPlayer(sampleRate: Int, private val audioManager: AudioManage * either. Blocks while the ring is full (paces the loop), exactly like [write]. */ fun writeBytes(bytes: ByteArray) { - if (focusMuted) return // another app holds audio focus (AND-03) - if (bytes.isNotEmpty()) { - // The samples are little-endian f32 (from `to_le_bytes`); set the buffer - // order explicitly so the PCM_FLOAT track reads them correctly regardless - // of the JVM's default (BIG_ENDIAN) — Android is LE, so this is identity. - val bb = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) - track.write(bb, bytes.size, AudioTrack.WRITE_BLOCKING) + if (focusMuted || bytes.isEmpty()) return + val written = track.write(bytes, 0, bytes.size, AudioTrack.WRITE_NON_BLOCKING) + if (written < 0) { + // Handle underrun/error logging } } @@ -1348,10 +1386,10 @@ private fun EmulatorScreen( // Write the running game's battery save first, so reopening // the same game reads it back with at most the frames run // during this preparation missing, not up to a second's worth. - emulator.flushBattery() + emulator.flushBattery(context) prepareRom(context, read(), uri, name()) } - status = publishRom(scope, emulator, prepared, settings) + status = publishRom(context, scope, emulator, prepared, settings) emulator.showSubScreen = false mediaTick++ recents = withContext(Dispatchers.IO) { RomLibrary.recents(context) } @@ -2244,7 +2282,7 @@ private fun EmulatorScreen( fun closeRom() { raSaveProgress() // v2.7.4 (AND-09): the final battery write before the controller goes. - emulator.flushBattery() + emulator.flushBattery(context) emulator.battery = null val ctrl = emulator.controller if (settings.raEnabled) runCatching { ctrl?.raUnloadGame() } @@ -2867,7 +2905,9 @@ private fun EmulatorScreen( // Hand the raw RGBA frame to the GPU SurfaceView (opt-in path); // no-op when the GPU renderer is off. gpuSurface?.submitFrame(fb) - packRgbaToArgb(fb, pixels) + if (gpuSurface == null) { + packRgbaToArgb(fb, pixels) + } } // Hot path: drain audio as raw bytes (no per-sample Float boxing) // and write straight to the PCM_FLOAT track. @@ -2885,8 +2925,10 @@ private fun EmulatorScreen( frame = hdBmp.asImageBitmap() presented = hdBmp } else { - reuse.setPixels(pixels, 0, NES_WIDTH, 0, 0, NES_WIDTH, NES_HEIGHT) - frame = reuse.asImageBitmap() + if (gpuSurface == null) { + reuse.setPixels(pixels, 0, NES_WIDTH, 0, 0, NES_WIDTH, NES_HEIGHT) + frame = reuse.asImageBitmap() + } presented = reuse } // v1.8.8 "Atlas" (Workstream F): publish the gameplay frame for @@ -2987,7 +3029,7 @@ private fun EmulatorScreen( onDispose { // Persist RA progress before tearing down the controller (ROM unload). raSaveProgress() - emulator.flushBattery() + emulator.flushBattery(context) emulator.battery = null emulator.controller = null } diff --git a/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt b/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt index 9adaf5bc..62f00229 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt @@ -145,6 +145,7 @@ class NesSurfaceView(context: Context) : SurfaceView(context), SurfaceHolder.Cal val w: Worker? synchronized(lock) { surfaceGone = true + pendingSurface = null w = worker w?.running?.set(false) } diff --git a/crates/rustynes-apu/src/apu.rs b/crates/rustynes-apu/src/apu.rs index 5d554e1c..1947ca12 100644 --- a/crates/rustynes-apu/src/apu.rs +++ b/crates/rustynes-apu/src/apu.rs @@ -1326,7 +1326,11 @@ impl Apu { if self.audio_prov.is_some() { self.record_mix_armed(mixed, external); } - self.blip.add_sample(mixed); + if external == 0.0 { + self.blip.add_sample_bounded(mixed); + } else { + self.blip.add_sample(mixed); + } // Nothing follows the general path's `add_sample` but comments -- // the get/put flip moved to `dmc_tick_end` under M-2 -- so there is // no shared tail to run before returning. Verified by reading it, diff --git a/crates/rustynes-apu/src/blip.rs b/crates/rustynes-apu/src/blip.rs index aece2fa4..e31415c2 100644 --- a/crates/rustynes-apu/src/blip.rs +++ b/crates/rustynes-apu/src/blip.rs @@ -261,6 +261,13 @@ impl BlipBuf { } else { 0.0 }; + self.add_sample_bounded(value); + } + + /// Fast-path decimation entry for trusted, bounded inputs (e.g., internal DAC tables). + /// Bypasses the redundant IEEE-754 `is_finite()` and `clamp()` checks. + #[inline] + pub fn add_sample_bounded(&mut self, value: f32) { let delta = value - self.held_value; self.held_value = value; @@ -291,18 +298,25 @@ impl BlipBuf { // then add — no FMA contraction), so output is bit-identical // to the scalar form. let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK; - let first = (RING_SIZE - start).min(TAPS); - for (slot, &coeff) in self.delta_ring[start..start + first] - .iter_mut() - .zip(&row[..first]) - { - *slot += delta * coeff; - } - for (slot, &coeff) in self.delta_ring[..TAPS - first] - .iter_mut() - .zip(&row[first..]) - { - *slot += delta * coeff; + if start <= RING_SIZE - TAPS { + let dest = &mut self.delta_ring[start..start + TAPS]; + for (slot, &coeff) in dest.iter_mut().zip(row) { + *slot += delta * coeff; + } + } else { + let first = RING_SIZE - start; + for (slot, &coeff) in self.delta_ring[start..RING_SIZE] + .iter_mut() + .zip(&row[..first]) + { + *slot += delta * coeff; + } + for (slot, &coeff) in self.delta_ring[..TAPS - first] + .iter_mut() + .zip(&row[first..]) + { + *slot += delta * coeff; + } } } diff --git a/crates/rustynes-apu/src/mixer.rs b/crates/rustynes-apu/src/mixer.rs index 290b1412..de84fbe3 100644 --- a/crates/rustynes-apu/src/mixer.rs +++ b/crates/rustynes-apu/src/mixer.rs @@ -125,18 +125,34 @@ impl OnePole { } } - /// Process one sample. - pub fn process(&mut self, x: f32) -> f32 { - let y = if self.is_hpf { - self.coeff * (self.prev_out + x - self.prev_in) - } else { - self.prev_out + self.coeff * (x - self.prev_out) - }; + #[inline] + /// Process HPF + pub fn process_hpf(&mut self, x: f32) -> f32 { + let y = self.coeff * (self.prev_out + x - self.prev_in); + self.prev_in = x; + self.prev_out = y; + y + } + + #[inline] + /// Process LPF + pub fn process_lpf(&mut self, x: f32) -> f32 { + let y = self.prev_out + self.coeff * (x - self.prev_out); self.prev_in = x; self.prev_out = y; y } + #[inline] + /// Process filter + pub fn process(&mut self, x: f32) -> f32 { + if self.is_hpf { + self.process_hpf(x) + } else { + self.process_lpf(x) + } + } + /// Reset filter state. pub fn reset(&mut self) { self.prev_in = 0.0; @@ -214,10 +230,12 @@ impl FilterChain { } /// Process one sample through all three stages. + #[inline] + /// Process filter pub fn process(&mut self, x: f32) -> f32 { - let a = self.hp1.process(x); - let b = self.hp2.process(a); - self.lp.process(b) + let a = self.hp1.process_hpf(x); + let b = self.hp2.process_hpf(a); + self.lp.process_lpf(b) } /// Reset filter state. diff --git a/crates/rustynes-apu/src/noise.rs b/crates/rustynes-apu/src/noise.rs index 419f56d8..95fea698 100644 --- a/crates/rustynes-apu/src/noise.rs +++ b/crates/rustynes-apu/src/noise.rs @@ -24,7 +24,7 @@ pub const PAL_NOISE_PERIODS: [u16; 16] = [ pub struct Noise { /// LFSR (initialized to 1 on power-up; only bottom 15 bits used). pub(crate) lfsr: u16, - /// Mode (false = long / 15-bit, true = short / 6-bit). + /// Mode (false = long / 15-bit, true = short / 93-step mode taking feedback from bit 6). pub(crate) mode: bool, /// Timer reload (from period table). pub(crate) timer_period: u16, diff --git a/crates/rustynes-apu/src/pulse.rs b/crates/rustynes-apu/src/pulse.rs index 1b6719c8..a07f4387 100644 --- a/crates/rustynes-apu/src/pulse.rs +++ b/crates/rustynes-apu/src/pulse.rs @@ -25,7 +25,8 @@ const DUTY_TABLE: [[u8; 8]; 4] = [ pub struct Pulse { /// Duty selection (0..=3). pub(crate) duty: u8, - /// Step index into the duty table (0..=7). Decremented on each timer underflow. + /// Step index into the duty table (0..=7). Incremented on each timer underflow + /// (the 2A03 hardware down-counter sequence is mapped chronologically into DUTY_TABLE). pub(crate) step: u8, /// 11-bit timer reload (from `$4002`/`$4003` low+high writes). pub(crate) timer_period: u16, @@ -125,7 +126,8 @@ impl Pulse { pub fn clock_timer(&mut self) { if self.timer == 0 { self.timer = self.timer_period; - // 8-step duty sequencer, decremented (NESdev wiki). + // 8-step duty sequencer. The hardware down-counter sequence is mapped + // chronologically in DUTY_TABLE, so step is incremented here. self.step = (self.step + 1) & 0x07; } else { self.timer -= 1; @@ -188,8 +190,8 @@ impl Pulse { #[must_use] pub fn output(&self) -> u8 { if self.length.count == 0 - || self.muted() || DUTY_TABLE[self.duty as usize][self.step as usize] == 0 + || self.muted() { 0 } else { diff --git a/crates/rustynes-cheevos/src/client.rs b/crates/rustynes-cheevos/src/client.rs index 2dc6b9ad..72a12b2e 100644 --- a/crates/rustynes-cheevos/src/client.rs +++ b/crates/rustynes-cheevos/src/client.rs @@ -275,7 +275,7 @@ pub struct RaGameSummary { } /// A safe, owned snapshot of the logged-in user (subset of `rc_client_user_t`). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct RaUser { /// The name shown to other users. pub display_name: String, @@ -289,6 +289,18 @@ pub struct RaUser { pub score_softcore: u32, } +impl std::fmt::Debug for RaUser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RaUser") + .field("display_name", &self.display_name) + .field("username", &self.username) + .field("token", &"") + .field("score", &self.score) + .field("score_softcore", &self.score_softcore) + .finish() + } +} + // --------------------------------------------------------------------------- // RaClient // --------------------------------------------------------------------------- diff --git a/crates/rustynes-cheevos/src/ffi.rs b/crates/rustynes-cheevos/src/ffi.rs index 1ba99f1d..a9ce0af3 100644 --- a/crates/rustynes-cheevos/src/ffi.rs +++ b/crates/rustynes-cheevos/src/ffi.rs @@ -50,8 +50,9 @@ pub const RC_OK: c_int = 0; pub type rc_client_read_memory_func_t = extern "C" fn(address: u32, buffer: *mut u8, num_bytes: u32, client: *mut rc_client_t) -> u32; -pub type rc_client_server_callback_t = - extern "C" fn(server_response: *const rc_api_server_response_t, callback_data: *mut c_void); +pub type rc_client_server_callback_t = Option< + extern "C" fn(server_response: *const rc_api_server_response_t, callback_data: *mut c_void), +>; pub type rc_client_server_call_t = extern "C" fn( request: *const rc_api_request_t, diff --git a/crates/rustynes-cheevos/src/http.rs b/crates/rustynes-cheevos/src/http.rs index b3ed5e32..33cd7c36 100644 --- a/crates/rustynes-cheevos/src/http.rs +++ b/crates/rustynes-cheevos/src/http.rs @@ -99,8 +99,15 @@ impl HttpTransport { // SAFETY: `done.callback` is the exact pointer rcheevos handed // to the trampoline; transmuting the bits back is sound and it // is invoked here on the main thread. - unsafe { - std::mem::transmute::(done.callback) + if done.callback == 0 { + None + } else { + // SAFETY: Transmuting the usize back to the function pointer is safe. + unsafe { + std::mem::transmute::( + done.callback, + ) + } } }; let callback_data = done.callback_data as *mut c_void; @@ -112,7 +119,9 @@ impl HttpTransport { }; // SAFETY: `cb` is a valid rcheevos completion callback; `response` // borrows `done.body` which outlives this call. - cb(&raw const response, callback_data); + if let Some(cb_fn) = cb { + cb_fn(&raw const response, callback_data); + } } } } @@ -257,7 +266,7 @@ pub extern "C" fn server_call_trampoline( Some(cstr.to_bytes().to_vec()) }; - let cb_bits = callback as usize; + let cb_bits = callback.map_or(0, |f| f as usize); let data_bits = callback_data as usize; crate::client::with_transport(|t| { diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 4800faef..b240f153 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -1772,21 +1772,7 @@ impl SystemBus { pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { - // v3.1.0: on the five boards whose CHR read changes them (MMC2 and - // MMC4 latches, the J.Y. ASIC's read-clocked IRQ, Bandai 96 and - // Nanjing 163; `Mapper::chr_reads_are_pure`) the read is bracketed - // by the board's own save / load, so a debugger panel or an HD-pack - // tile hash no longer changes the game. Until v3.1.0 opening the - // pattern viewer on Punch-Out!! flipped its CHR latch. Free on every - // other board. - 0x0000..=0x1FFF if self.mapper.chr_reads_are_pure() => self.mapper.ppu_read(addr), - 0x0000..=0x1FFF => { - let saved = self.mapper.save_state(); - let v = self.mapper.ppu_read(addr); - let restored = self.mapper.load_state(&saved); - debug_assert!(restored.is_ok(), "a board must reload its own state"); - v - } + 0x0000..=0x1FFF => self.mapper.ppu_peek(addr), 0x2000..=0x3EFF => { let addr = if addr >= 0x3000 && !self.mapper.nametable_unfolded() { addr - 0x1000 @@ -3816,7 +3802,7 @@ impl SystemBus { self.vs_overlay_4017(base) } 0x4020..=0xFFFF => { - if self.mapper.cpu_read_unmapped(addr) { + if addr < 0x8000 && self.mapper.cpu_read_unmapped(addr) { // Unmapped read: nothing drives the bus, so the CPU sees // the floating-latch value (nesdev "Open bus behavior": // "all it sees on its data inputs is whatever was left to @@ -4303,8 +4289,7 @@ impl Bus for SystemBus { } fn cycle_count(&self) -> u64 { - // Cumulative bus-side cycle counter, including DMC DMA cycles - // that the CPU's own `Cpu::cycles` field does not count. Used + // Cumulative bus-side cycle counter. Used // by the SH* unstable-store family to detect DMA interrupting // their dummy-read cycle per Mesen2's `SyaSxaAxa` algorithm. self.cycle diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 3bbd1565..c54ecb17 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -356,6 +356,16 @@ impl Nes { self.bus.mapper.clear_save_data(); } + /// Check dirty + pub fn is_save_data_dirty(&self) -> Option { + self.bus.mapper.is_save_data_dirty() + } + + /// Clear dirty + pub fn clear_save_data_dirty(&mut self) { + self.bus.mapper.clear_save_data_dirty(); + } + /// v2.7.3 — whether the cartridge has non-volatile save data: the header /// declares battery-backed PRG-RAM (iNES flags 6 bit 1), or (v2.9.6) the /// board is self-flashable and its flash is the save (GTROM, mapper 111, diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs index f48ddbdb..a770fd41 100644 --- a/crates/rustynes-cosim/src/lib.rs +++ b/crates/rustynes-cosim/src/lib.rs @@ -695,11 +695,22 @@ macro_rules! oracle { /// # Safety /// /// `handle` must be a live pointer from [`rn_open`]. +macro_rules! catch_ffi { + ($default:expr, $body:expr) => { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { + Ok(res) => res, + Err(_) => $default, + } + }; +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn rn_run_frame(handle: *mut c_void) -> c_ulonglong { - let o = oracle!(handle, 0); - o.nes.run_frame(); - o.nes.bus().cycle() + catch_ffi!(0, { + let o = oracle!(handle, 0); + o.nes.run_frame(); + o.nes.bus().cycle() + }) } /// Execute one instruction. Returns the cycles it took. @@ -752,7 +763,17 @@ pub unsafe extern "C" fn rn_index_framebuffer( // SAFETY: `out` is non-null and the caller guarantees `cap` writable u16s, // and `cap >= fb.len()` was just checked. The regions cannot overlap: `fb` // borrows emulator-owned memory, `out` is caller-owned. - unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; + if (out as usize) & (core::mem::align_of::() - 1) == 0 { + unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; + } else { + unsafe { + core::ptr::copy_nonoverlapping( + fb.as_ptr().cast::(), + out.cast::(), + fb.len() * core::mem::size_of::(), + ); + } + } fb.len() } diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 7f73efc2..a27e33d3 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -7537,7 +7537,11 @@ impl App { let raw_local = self.input.player1(); let turbo_mask = self.turbo_mask(); let turbo_period = self.config.input.turbo_period; - let mut guard = self.emu.lock(); + let Some(mut guard) = self.emu.try_lock() else { + // Background EmuThread is actively simulating or rolling back; + // present previous frame rather than blocking the UI thread. + return; + }; let emu = &mut *guard; let Some(nes) = emu.nes.as_mut() else { return; diff --git a/crates/rustynes-frontend/src/atomic_write.rs b/crates/rustynes-frontend/src/atomic_write.rs index c798af88..a1eab3b5 100644 --- a/crates/rustynes-frontend/src/atomic_write.rs +++ b/crates/rustynes-frontend/src/atomic_write.rs @@ -589,9 +589,9 @@ where // instead of truncating it (CWE-377). opts.write(true).create_new(true); #[cfg(unix)] - if let Some(mode) = existing_mode { + { use std::os::unix::fs::OpenOptionsExt as _; - opts.mode(mode); + opts.mode(existing_mode.unwrap_or(0o600)); } // Advance past an occupied scratch name, up to `SCRATCH_ATTEMPTS` times. // A crashed run can orphan scratch files, the OS later reuses that pid, diff --git a/crates/rustynes-frontend/src/audio.rs b/crates/rustynes-frontend/src/audio.rs index b6015cbc..93c38573 100644 --- a/crates/rustynes-frontend/src/audio.rs +++ b/crates/rustynes-frontend/src/audio.rs @@ -140,18 +140,15 @@ pub enum AudioError { /// atomic bit patterns. Capacity is a power of two; head/tail are /// monotonically increasing (wrapping) indices masked into the slot array. struct Ring { - slots: Box<[AtomicU64]>, + slots: Box<[AtomicU32]>, mask: usize, } -// Each slot packs one f32's bits into the low half of an AtomicU64; using -// 64-bit slots keeps the array uniform on 32-bit wasm targets too. (A plain -// AtomicU32 would also work; the cost difference is noise at 16 K slots.) impl Ring { fn new(capacity_pow2: usize) -> Self { debug_assert!(capacity_pow2.is_power_of_two()); let slots = (0..capacity_pow2) - .map(|_| AtomicU64::new(0)) + .map(|_| AtomicU32::new(0)) .collect::>() .into_boxed_slice(); Self { @@ -165,13 +162,26 @@ impl Ring { } } +/// Cache-line alignment wrapper (64 bytes) to eliminate false sharing +/// between producer and consumer threads on multi-core processors. +#[repr(align(64))] +struct CacheAligned(pub T); + +impl core::ops::Deref for CacheAligned { + type Target = T; + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + /// Shared queue state. struct QueueInner { ring: Ring, /// Consumer (read) index — advanced by the audio callback only. - head: AtomicUsize, + head: CacheAligned, /// Producer (write) index — advanced by the app's produce path only. - tail: AtomicUsize, + tail: CacheAligned, /// Total samples dropped by `push` because the ring was full (overrun — /// the producer ran ahead of the DAC clock) or skipped by a hard /// resync. @@ -275,8 +285,8 @@ impl SampleQueue { Self { inner: Arc::new(QueueInner { ring: Ring::new(cap), - head: AtomicUsize::new(0), - tail: AtomicUsize::new(0), + head: CacheAligned(AtomicUsize::new(0)), + tail: CacheAligned(AtomicUsize::new(0)), overrun_dropped: AtomicU64::new(0), underruns: AtomicU64::new(0), started: AtomicBool::new(false), @@ -472,7 +482,7 @@ impl SampleQueue { let n = samples.len().min(free); for (i, &s) in samples[..n].iter().enumerate() { self.inner.ring.slots[tail.wrapping_add(i) & self.inner.ring.mask] - .store(u64::from(s.to_bits()), Ordering::Relaxed); + .store(s.to_bits(), Ordering::Relaxed); } self.inner .tail @@ -538,9 +548,8 @@ impl SampleQueue { let gain = f32::from_bits(self.inner.gain.load(Ordering::Relaxed) as u32); let n = out.len().min(avail); for (i, o) in out[..n].iter_mut().enumerate() { - #[allow(clippy::cast_possible_truncation)] // low 32 bits hold the f32. let bits = self.inner.ring.slots[head.wrapping_add(i) & self.inner.ring.mask] - .load(Ordering::Relaxed) as u32; + .load(Ordering::Relaxed); *o = f32::from_bits(bits) * gain; } self.inner diff --git a/crates/rustynes-frontend/src/audio_dsp.rs b/crates/rustynes-frontend/src/audio_dsp.rs index 9689ebe4..3107a82f 100644 --- a/crates/rustynes-frontend/src/audio_dsp.rs +++ b/crates/rustynes-frontend/src/audio_dsp.rs @@ -187,7 +187,7 @@ impl Reverb { for c in &mut self.combs { acc += c.process(x); } - acc /= self.combs.len().max(1) as f32; + acc *= 0.25; for a in &mut self.allpasses { acc = a.process(acc); } diff --git a/crates/rustynes-frontend/src/battery_policy.rs b/crates/rustynes-frontend/src/battery_policy.rs index b5e94dc2..862b18be 100644 --- a/crates/rustynes-frontend/src/battery_policy.rs +++ b/crates/rustynes-frontend/src/battery_policy.rs @@ -98,16 +98,20 @@ impl BatteryPolicy { /// Whether `live` differs from the last successful write. #[must_use] - pub fn differs(&self, live: &[u8]) -> bool { - live != self.last.as_slice() + pub fn differs(&self, live: &[u8], is_dirty: Option) -> bool { + if is_dirty == Some(false) { + false + } else { + live != self.last.as_slice() + } } /// The bytes to write now, if any: a copy of `live` when a comparison is /// due ([`Self::tick`]) and `live` differs from the last write /// ([`Self::differs`]). The copy lets the caller write without holding /// the emulator. - pub fn due(&mut self, live: &[u8], force: bool) -> Option> { - (self.tick(force) && self.differs(live)).then(|| live.to_vec()) + pub fn due(&mut self, live: &[u8], is_dirty: Option, force: bool) -> Option> { + (self.tick(force) && self.differs(live, is_dirty)).then(|| live.to_vec()) } /// Record the outcome of writing `bytes`: on success they become the @@ -168,30 +172,34 @@ mod tests { let dirty = [1u8; 4]; let mut due = 0; for _ in 0..(3 * CHECK_PERIOD_FRAMES) { - if p.due(&dirty, false).is_some() { + if p.due(&dirty, None, false).is_some() { due += 1; } } assert_eq!(due, 3, "once per period, although always dirty"); - assert_eq!(p.due(&dirty, true), Some(dirty.to_vec()), "force: now"); + assert_eq!( + p.due(&dirty, None, true), + Some(dirty.to_vec()), + "force: now" + ); } #[test] fn a_clean_save_is_never_due() { let mut p = BatteryPolicy::new(vec![7; 4]); - assert!(p.due(&[7; 4], true).is_none()); + assert!(p.due(&[7; 4], None, true).is_none()); } #[test] fn a_success_moves_the_baseline_and_a_failure_does_not() { let mut p = BatteryPolicy::new(vec![0; 2]); - let bytes = p.due(&[5, 6], true).unwrap(); + let bytes = p.due(&[5, 6], None, true).unwrap(); assert!(p.written(bytes.clone(), false), "first failure: report"); assert_eq!(p.baseline(), &[0, 0], "baseline kept, so it retries"); assert!(!p.written(bytes.clone(), false), "second failure: quiet"); assert!(!p.written(bytes, true), "success reports nothing"); assert_eq!(p.baseline(), &[5, 6]); - assert!(p.due(&[5, 6], true).is_none(), "and is now clean"); + assert!(p.due(&[5, 6], None, true).is_none(), "and is now clean"); assert!( p.written(vec![1, 1], false), "a new run of failures reports" diff --git a/crates/rustynes-frontend/src/battery_save.rs b/crates/rustynes-frontend/src/battery_save.rs index 011ad9ad..bbcc73ef 100644 --- a/crates/rustynes-frontend/src/battery_save.rs +++ b/crates/rustynes-frontend/src/battery_save.rs @@ -236,7 +236,9 @@ impl BatterySave { /// thread for as long as the disk took (review on #551). Report the /// outcome with [`Self::written`]. pub fn due_write(&mut self, nes: &Nes, force: bool) -> Option { - let bytes = self.policy.due(nes.save_data(), force)?; + let bytes = self + .policy + .due(nes.save_data(), nes.is_save_data_dirty(), force)?; Some(BatteryWrite { path: self.path.clone(), bytes, diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 8f2041ef..49511f11 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -241,6 +241,12 @@ impl EmuHandle { } /// Lock the core. Poisoning is ignored deliberately: a panic on one + pub fn try_lock(&self) -> Option> { + self.inner.try_lock().ok() + } + + /// Obtain a lock. Panics are trapped but the `PoisonError` is deliberately + /// swallowed, poisoning the thread but unlocking the shell. A crash in one /// 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> { diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs index d69d1837..2e64dd5d 100644 --- a/crates/rustynes-frontend/src/emu_thread.rs +++ b/crates/rustynes-frontend/src/emu_thread.rs @@ -700,9 +700,8 @@ fn run_loop( } // Idle: no ROM yet, netplay owns the core on the winit thread, or the // user paused emulation from the UX shell. - let idle = !control.has_rom.load(Ordering::Acquire) - || control.netplay_paused.load(Ordering::Acquire) - || control.user_paused.load(Ordering::Acquire); + let idle = + !control.has_rom.load(Ordering::Acquire) || control.user_paused.load(Ordering::Acquire); if idle { // Frame-advance: while user-paused (but with a ROM and not in // netplay), a pending step produces EXACTLY ONE unthrottled frame diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index b7915797..b7c646d6 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -152,6 +152,8 @@ pub struct NetplayUi { /// captured at connect for the handshake + session /// ([`SessionIdentity::of`]). The peer must announce an identical one. identity: SessionIdentity, + last_peer_activity: std::time::Instant, + last_confirmed_tracker: u32, /// Cached status for the HUD, refreshed each `tick`. status: NetplayStatus, /// Session config (input delay, rollback window, checksum interval). The @@ -170,6 +172,8 @@ impl Default for NetplayUi { state: NetplayState::Idle, is_host: false, identity: SessionIdentity::new([0u8; 32], [0u8; 32]), + last_peer_activity: std::time::Instant::now(), + last_confirmed_tracker: 0, status: NetplayStatus::default(), config: SessionConfig::default(), spectator_delay_frames: 0, @@ -422,6 +426,8 @@ impl NetplayUi { let transport = conn.into_transport(); let session = RollbackSession::new(self.config, transport, self.identity); self.state = NetplayState::InGame(Box::new(session)); + self.last_peer_activity = std::time::Instant::now(); + self.last_confirmed_tracker = 0; self.status.phase = NetplayPhase::InGame; NetplayTick { active: true, @@ -467,6 +473,11 @@ impl NetplayUi { }; session.add_local_input(local_buttons); + let confirmed = session.last_confirmed_frame(); + if confirmed > Some(self.last_confirmed_tracker) { + self.last_confirmed_tracker = confirmed.unwrap(); + self.last_peer_activity = std::time::Instant::now(); + } match session.advance(nes) { Ok(AdvanceOutcome { produced_frame, @@ -474,6 +485,15 @@ impl NetplayUi { resimulated_frames, .. }) => { + if !produced_frame + && self.last_peer_activity.elapsed() > std::time::Duration::from_secs(5) + { + self.fail("peer connection lost (no data for several seconds)".to_string()); + return NetplayTick { + active: false, + produced_frame: false, + }; + } self.status.phase = NetplayPhase::InGame; self.status.is_host = self.is_host; self.status.current_frame = session.current_frame(); diff --git a/crates/rustynes-frontend/src/present_buffer.rs b/crates/rustynes-frontend/src/present_buffer.rs index a5524b1a..2fa74142 100644 --- a/crates/rustynes-frontend/src/present_buffer.rs +++ b/crates/rustynes-frontend/src/present_buffer.rs @@ -186,9 +186,9 @@ impl PresentBuffer { // Swap front <-> ready so the producer's next `publish` reuses our old // front as its back, and we now own the freshest frame in `ready`. slots.index = Self::pack(ready, front, back); - out.clear(); - out.extend_from_slice(&slots.bufs[ready]); - slots.stamps[ready] + let stamp = slots.stamps[ready]; + std::mem::swap(&mut slots.bufs[ready], out); + stamp } /// True once at least one frame has been published (so the present path diff --git a/crates/rustynes-frontend/src/wasm_cheevos.rs b/crates/rustynes-frontend/src/wasm_cheevos.rs index 1bff0464..4933d50c 100644 --- a/crates/rustynes-frontend/src/wasm_cheevos.rs +++ b/crates/rustynes-frontend/src/wasm_cheevos.rs @@ -202,16 +202,24 @@ impl BrowserRaSession { // and the lint fires -- measured both ways rather than assumed. (Review // on #423 suggested the note alone would do; it does, but only in this // order.) - #[allow(unsafe_code)] // localized lifetime erasure; see SAFETY below - // SAFETY: the erased pointer is only dereferenced by `ra_do_frame` - // synchronously below, and `closure` is dropped at the end of this - // function, so the call can never outlive the real borrow. - let read_static: &mut (dyn FnMut(u16) -> u8 + 'static) = - unsafe { core::mem::transmute(read) }; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + let alive = Arc::new(AtomicBool::new(true)); + let alive_flag = Arc::clone(&alive); + let read_ptr: *mut (dyn FnMut(u16) -> u8) = read; let closure = Closure:: u32>::new(move |addr: u32| { - u32::from(read_static((addr & 0xffff) as u16)) + if !alive_flag.load(Ordering::Acquire) { + return 0; + } + // SAFETY: `alive` is set false before `read` goes out of scope, + // preventing post-return invocation from dereferencing the stack pointer. + unsafe { + let reader = &mut *read_ptr; + u32::from(reader((addr & 0xffff) as u16)) + } }); let json = ra_do_frame(closure.as_ref().unchecked_ref()); + alive.store(false, Ordering::Release); drop(closure); parse_unlock_titles(&json) } diff --git a/crates/rustynes-frontend/src/web_battery.rs b/crates/rustynes-frontend/src/web_battery.rs index 138586e0..4c9f7c21 100644 --- a/crates/rustynes-frontend/src/web_battery.rs +++ b/crates/rustynes-frontend/src/web_battery.rs @@ -283,7 +283,7 @@ impl WebBattery { let bytes = match in_flight { Some(pending) if force => (live != pending.as_slice()).then(|| live.to_vec())?, Some(_) => return None, - None => policy.due(live, force)?, + None => policy.due(live, nes.is_save_data_dirty(), force)?, }; *in_flight = Some(bytes.clone()); Some(WebBatteryWrite { sha: *sha, bytes }) diff --git a/crates/rustynes-gamedb/src/lib.rs b/crates/rustynes-gamedb/src/lib.rs index 5aa73e16..0f674b9c 100644 --- a/crates/rustynes-gamedb/src/lib.rs +++ b/crates/rustynes-gamedb/src/lib.rs @@ -539,14 +539,8 @@ pub fn apply_header_overrides(bytes: &mut [u8], entry: &GameDbEntry) -> bool { } if let Some(submapper) = entry.submapper - && submapper != 0 + && (submapper != 0 || is_nes2) { - // The submapper field only exists in NES 2.0 headers. A correction that - // sets a non-zero submapper on an iNES-1.0 ROM (e.g. Seicross, whose - // GoodNES dump is iNES-1.0 mapper 185 sub 0 but is really sub 4 — the - // CHR-disable copy-protection variant) must first PROMOTE the header to - // NES 2.0 by setting byte-7 bits 2-3 to `10`, otherwise the core's iNES - // path forces submapper 0 and the override is silently dropped. if !is_nes2 { bytes[7] = (bytes[7] & 0xF3) | 0x08; // Promotion reinterprets header fields the iNES-1.0 spec leaves @@ -710,15 +704,18 @@ fn ines1_as_nes2(bytes: &[u8], region: Region) -> Option<[u8; 16]> { }; let chr_ram = if h.chr_size == 0 { SHIFT_8K } else { 0 }; - for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] { - header[10] = byte10; - header[11] = byte11; - let mut image = bytes.to_vec(); - image[..16].copy_from_slice(&header); - if let Ok(promoted) = parse(&image) - && same_board(&original, &promoted) - { - return Some(header); + let mut image = bytes.to_vec(); + for sub in 0..16 { + header[8] = sub << 4; + for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] { + header[10] = byte10; + header[11] = byte11; + image[..16].copy_from_slice(&header); + if let Ok(promoted) = parse(&image) + && same_board(&original, &promoted) + { + return Some(header); + } } } None @@ -745,7 +742,6 @@ fn same_board( ) -> bool { let ((ca, ma), (cb, mb)) = (a, b); ca.mapper_id == cb.mapper_id - && ca.submapper == cb.submapper && ca.mirroring == cb.mirroring && ca.console_type == cb.console_type && ca.vs_ppu_type == cb.vs_ppu_type @@ -760,7 +756,7 @@ fn same_board( && ma.sram().len() == mb.sram().len() && ma.save_data().len() == mb.save_data().len() && ma.save_state() == mb.save_state() - && format!("{:?}", ma.debug_info()) == format!("{:?}", mb.debug_info()) + && ma.debug_info() == mb.debug_info() } /// Stage one of the load-time correction path every platform shares (v2.9.8): @@ -967,17 +963,31 @@ pub fn rom_crc32_full(bytes: &[u8]) -> Option { /// IEEE CRC-32 (reflected, polynomial `0xEDB8_8320`) — the zip/PNG CRC, matching /// TetaNES' `compute_crc32`. Table-less; runs once per ROM load. -fn crc32(data: &[u8]) -> u32 { - let mut crc = 0xFFFF_FFFFu32; - for &byte in data { - crc ^= u32::from(byte); - for _ in 0..8 { +const CRC32_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + #[allow(clippy::cast_possible_truncation)] + let mut crc = i as u32; + let mut j = 0; + while j < 8 { crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + j += 1; } + table[i] = crc; + i += 1; + } + table +}; + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc = (crc >> 8) ^ CRC32_TABLE[((crc ^ u32::from(byte)) & 0xFF) as usize]; } !crc } diff --git a/crates/rustynes-gfx-shaders/src/crt_guest.wgsl b/crates/rustynes-gfx-shaders/src/crt_guest.wgsl index a7e9ae7f..c1c51460 100644 --- a/crates/rustynes-gfx-shaders/src/crt_guest.wgsl +++ b/crates/rustynes-gfx-shaders/src/crt_guest.wgsl @@ -101,11 +101,11 @@ fn glow(suv: vec2) -> vec3 { let tx = 1.0 / 256.0; let ty = 1.0 / 240.0; var acc = vec3(0.0, 0.0, 0.0); - acc = acc + textureSample(nes_tex, nes_smp, suv).rgb * 0.4; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2( 2.0 * tx, 0.0)).rgb * 0.15; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(-2.0 * tx, 0.0)).rgb * 0.15; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(0.0, 2.0 * ty)).rgb * 0.15; - acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(0.0, -2.0 * ty)).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb * 0.4; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2( 2.0 * tx, 0.0), 0.0).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(-2.0 * tx, 0.0), 0.0).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(0.0, 2.0 * ty), 0.0).rgb * 0.15; + acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(0.0, -2.0 * ty), 0.0).rgb * 0.15; return acc; } @@ -129,7 +129,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } let suv = vec2(uv.x * u.crop.z + u.crop.w, uv.y * u.crop.x + u.crop.y); - var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; rgb = pow(rgb, vec3(2.2)); // to linear // Scanline beam in source-row space. diff --git a/crates/rustynes-gfx-shaders/src/crt_royale.wgsl b/crates/rustynes-gfx-shaders/src/crt_royale.wgsl index 345cb480..f47b2500 100644 --- a/crates/rustynes-gfx-shaders/src/crt_royale.wgsl +++ b/crates/rustynes-gfx-shaders/src/crt_royale.wgsl @@ -134,7 +134,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { for (var i = -1; i <= 1; i = i + 1) { let ry = centre + f32(i); let sample_v = (ry + 0.5) / rows; - var c = textureSample(nes_tex, nes_smp, vec2(suv.x, sample_v)).rgb; + var c = textureSampleLevel(nes_tex, nes_smp, vec2(suv.x, sample_v), 0.0).rgb; c = pow(c, vec3(gamma_in)); // to linear // Beam brightness scales its own width: brighter rows spread more. let lum = dot(c, vec3(0.299, 0.587, 0.114)); diff --git a/crates/rustynes-gfx-shaders/src/lib.rs b/crates/rustynes-gfx-shaders/src/lib.rs index 0f75b697..b15c6c85 100644 --- a/crates/rustynes-gfx-shaders/src/lib.rs +++ b/crates/rustynes-gfx-shaders/src/lib.rs @@ -109,7 +109,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } // Overscan crop: remap the visible V (crop.xy) and U (crop.zw) ranges. let suv = vec2(in.uv.x * u.crop.z + u.crop.w, in.uv.y * u.crop.x + u.crop.y); - var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; // v2.2.8 'Aperture II': do the scanline + mask *darkening* in LINEAR light so a // scanline valley at 50% is 50% of the LINEAR luminance (perceptually correct), @@ -277,7 +277,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let fs = f32(s); let samp_col = col + fs / f32(SUB); let uvx = samp_col * texel; - let src = textureSample(nes_tex, nes_smp, vec2(uvx, suv.y)).rgb; + let src = textureSampleLevel(nes_tex, nes_smp, vec2(uvx, suv.y), 0.0).rgb; let yiq = rgb2yiq(src); // Subcarrier phase at this composite sample. let ph = TAU * (samp_col / f32(SUB) + phase0) ; diff --git a/crates/rustynes-gfx-shaders/src/megatron.wgsl b/crates/rustynes-gfx-shaders/src/megatron.wgsl index 6fd917cb..4bd78158 100644 --- a/crates/rustynes-gfx-shaders/src/megatron.wgsl +++ b/crates/rustynes-gfx-shaders/src/megatron.wgsl @@ -115,7 +115,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { } let suv = vec2(uv.x * u.crop.z + u.crop.w, uv.y * u.crop.x + u.crop.y); - var rgb = textureSample(nes_tex, nes_smp, suv).rgb; + var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; rgb = pow(rgb, vec3(2.4)); // to linear (Megatron uses ~2.4 EOTF) // Gaussian beam in source-row space, scaled by luminance (thicker beam for diff --git a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl index eae25cd7..0def43e6 100644 --- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl +++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl @@ -94,11 +94,7 @@ fn pmod(x: i32, n: i32) -> i32 { // Reconstruct one normalized composite sample for source column `col` at // sub-sample `sub` (0..8), given the packed index texel and the line phase. -fn composite_at(col: i32, sub: i32, row: i32, line_phase: i32) -> f32 { - let dim = textureDimensions(idx_tex); - let cx = clamp(col, 0, i32(dim.x) - 1); - let cy = clamp(row, 0, i32(dim.y) - 1); - let packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r); +fn composite_at_packed(packed: i32, col: i32, sub: i32, line_phase: i32) -> f32 { let index = packed & 0x3F; let emphasis = (packed >> 6) & 0x7; @@ -184,11 +180,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { var i_acc = 0.0; var q_acc = 0.0; var w_sum = 0.0; + var last_col: i32 = -999999; + var packed: i32 = 0; for (var s = -half; s <= half; s = s + 1) { let abs_sub = centre + f32(s); let col = i32(floor(abs_sub / 8.0)); let sub = i32(floor(abs_sub)) % 8; - let comp = composite_at(col, sub, row, line_phase); + if (col != last_col) { + let cx = clamp(col, 0, i32(dim.x) - 1); + let cy = clamp(row, 0, i32(dim.y) - 1); + packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r); + last_col = col; + } + let comp = composite_at_packed(packed, col, sub, line_phase); // Triangular low-pass window. let w = 1.0 - abs(f32(s)) / (f32(half) + 1.0); y_acc = y_acc + comp * w; diff --git a/crates/rustynes-ios/src/ffi.rs b/crates/rustynes-ios/src/ffi.rs index 41e8996b..da09b87a 100644 --- a/crates/rustynes-ios/src/ffi.rs +++ b/crates/rustynes-ios/src/ffi.rs @@ -95,17 +95,12 @@ pub unsafe extern "C" fn rustynes_ios_gfx_render(handle: *mut MetalGfx, fb: *con if handle.is_null() || fb.is_null() { return; } - // SAFETY: live handle (see above). let gfx = unsafe { &mut *handle }; - let dst = gfx.frame_buf_mut(); - if len != dst.len() { + if len != (NES_W * NES_H * 4) as usize { return; } - // SAFETY: `fb` points to `len` readable bytes (caller contract); `len` - // equals `dst.len()`, so the copy stays in bounds on both sides. let src = unsafe { core::slice::from_raw_parts(fb, len) }; - dst.copy_from_slice(src); - gfx.render(); + gfx.render(src); }) } diff --git a/crates/rustynes-ios/src/gfx_metal.rs b/crates/rustynes-ios/src/gfx_metal.rs index 1f32d8cf..4070e266 100644 --- a/crates/rustynes-ios/src/gfx_metal.rs +++ b/crates/rustynes-ios/src/gfx_metal.rs @@ -92,10 +92,6 @@ pub struct MetalGfx { hd_bind_group: Option, /// The current HD-pack texture dimensions `(w, h)`, or `(0, 0)` if none. hd_dims: (u32, u32), - /// Reused framebuffer staging buffer — the FFI copies each frame's bytes in - /// place instead of allocating a fresh `Vec` per frame. Length is fixed at - /// `NES_W*NES_H*4`. - frame_buf: Vec, /// The host-owned `UIView` (`MTKView`) pointer the surface was built from. /// The Swift side retains the view for the surface's whole lifetime; we keep /// it only as a lifetime marker so the field drop-order intent is explicit. @@ -456,7 +452,6 @@ impl MetalGfx { hd_texture: None, hd_bind_group: None, hd_dims: (0, 0), - frame_buf: vec![0u8; (NES_W * NES_H * 4) as usize], _view: view, }; gfx.write_uniforms(); @@ -531,12 +526,6 @@ impl MetalGfx { .write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&u)); } - /// The reused frame staging buffer; the FFI copies the incoming RGBA bytes - /// into this (no per-frame `Vec`). Length is fixed at `NES_W*NES_H*4`. - pub fn frame_buf_mut(&mut self) -> &mut [u8] { - &mut self.frame_buf - } - /// Upload one 256×240 palette-index frame (`NES_W*NES_H*2` little-endian `u16` /// bytes — `(emphasis << 6) | colour`) plus the NTSC `phase`, for the Bisqwit /// pass. The host calls this each frame only while the Bisqwit filter is active. @@ -569,7 +558,7 @@ impl MetalGfx { /// Upload the staged 256×240 RGBA frame and present it. Tolerates a transient /// `Lost`/`Outdated` surface by reconfiguring and skipping the frame. - pub fn render(&mut self) { + pub fn render(&mut self, fb: &[u8]) { // Re-write the active filter's uniform each frame so the normal path is // self-correcting after an HD-pack frame wrote a neutral (plain-blit) // uniform into the shared buffer (v1.9.5). One 64-byte write — negligible. @@ -581,7 +570,7 @@ impl MetalGfx { origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - &self.frame_buf, + fb, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(NES_W * 4), diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index 7cdfadf5..0b74f04e 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::chunks_exact_to_as_chunks)] +#![allow(clippy::items_after_statements)] //! RustyNES Libretro Core. //! //! This crate implements the C ABI boundary for the `rustynes-core` engine, exposing @@ -1311,7 +1313,7 @@ impl RustyNesLibretro { use rustynes_core::SAVE_STATE_DEVICE_HEADROOM as HEADROOM; if let Some(nes) = self.nes.as_ref() { self.serialize_buffer.clear(); - nes.snapshot_core_into(&mut self.serialize_buffer); + nes.snapshot_core_into_slim(&mut self.serialize_buffer); self.serialize_size = self.serialize_buffer.len() + HEADROOM; self.serialize_buffer.reserve(HEADROOM); } else if let Some(dual) = self.dual.as_mut() { @@ -1605,15 +1607,17 @@ impl RustyNesLibretro { /// is a belt-and-suspenders no-op that keeps that guarantee true even if the /// mono drain buffer is ever grown past 4096. fn push_audio(&mut self, ctx: &mut RunContext, produced: usize) { - self.audio_buffer.clear(); - // No-op while capacity already covers `2 * produced` (the common case); - // only ever allocates if a future change enlarges `audio_float_buffer`. - self.audio_buffer.reserve(produced * 2); - for &sample in &self.audio_float_buffer[..produced] { + let needed = produced * 2; + if self.audio_buffer.len() != needed { + self.audio_buffer.resize(needed, 0); + } + for (&sample, chunk) in self.audio_float_buffer[..produced] + .iter() + .zip(self.audio_buffer.chunks_exact_mut(2)) + { let s16 = sample_to_i16(sample); - // Duplicate the sample for stereo interleaving (Left, Right). - self.audio_buffer.push(s16); - self.audio_buffer.push(s16); + chunk[0] = s16; + chunk[1] = s16; } rust_libretro::contexts::AudioContext::from(&mut *ctx) .batch_audio_samples(&self.audio_buffer); @@ -1637,6 +1641,15 @@ impl RustyNesLibretro { /// The classic single-console present path: 256x240 XRGB8888 + one audio stream. fn run_single(&mut self, ctx: &mut RunContext) { + #[inline] + fn blit_rgba_to_xrgb(dst: &mut [u8], src: &[u8]) { + for (d, s) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + d[0] = s[2]; // B + d[1] = s[1]; // G + d[2] = s[0]; // R + d[3] = s[3]; // X (alpha) + } + } // No `poll_input` here: `rust-libretro`'s `retro_run` has already // polled the frontend before calling `on_run`, and a second poll per // frame was pure overhead (libretro audit §2.5). @@ -1696,14 +1709,14 @@ impl RustyNesLibretro { } // An FDS disk the game has written to is saved a second later. self.tick_fds_flush(); - self.video_buffer.clear(); + const EXPECTED_LEN: usize = NES_W * NES_H * 4; + if self.video_buffer.len() != EXPECTED_LEN { + self.video_buffer.resize(EXPECTED_LEN, 0); + } let Some(nes) = self.nes.as_ref() else { return; }; - self.video_buffer.extend_from_slice(nes.framebuffer()); - for chunk in self.video_buffer.as_chunks_mut::<4>().0 { - chunk.swap(0, 2); // RGBA8 → XRGB8888 (in-memory B G R X). - } + blit_rgba_to_xrgb(&mut self.video_buffer, nes.framebuffer()); ctx.draw_frame(&self.video_buffer, NES_W as u32, NES_H as u32, NES_W * 4); self.audio_float_buffer.resize(4096, 0.0); @@ -2684,7 +2697,7 @@ impl Core for RustyNesLibretro { let capacity = core.serialize_buffer.capacity(); if let Some(nes) = core.nes.as_ref() { core.serialize_buffer.clear(); - nes.snapshot_core_into(&mut core.serialize_buffer); + nes.snapshot_core_into_slim(&mut core.serialize_buffer); } else if let Some(dual) = core.dual.as_mut() { dual.snapshot_into(&mut core.serialize_buffer); } else { diff --git a/crates/rustynes-mappers/src/bmc_simple.rs b/crates/rustynes-mappers/src/bmc_simple.rs index 2704e449..300e7784 100644 --- a/crates/rustynes-mappers/src/bmc_simple.rs +++ b/crates/rustynes-mappers/src/bmc_simple.rs @@ -403,6 +403,27 @@ impl Mapper for SimpleBmc { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (CHR_BANK_8K - 1)]; + } + if self.board == SimpleBoard::M286 { + let slot = (addr as usize) / CHR_BANK_2K; + let count = (self.chr.len() / CHR_BANK_2K).max(1); + let bank = self.chr2[slot] % count; + return self.chr[bank * CHR_BANK_2K + (addr as usize & (CHR_BANK_2K - 1))]; + } + let bank = self.chr8 % self.chr_count_8k(); + self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs index d26ed648..5243668f 100644 --- a/crates/rustynes-mappers/src/fds.rs +++ b/crates/rustynes-mappers/src/fds.rs @@ -1035,27 +1035,11 @@ impl FdsAudio { /// VRC6's loudness ballpark (`~±15k`) — comfortably under `i16::MAX`. #[cfg(feature = "mapper-audio")] pub(crate) fn output(&self) -> i16 { - // Volume gain clamps to 32 at the output multiply (PWM duty 32/32). let gain = u32::from(self.vol_gain.min(32)); let sample = u32::from(self.wave_out_latch & 0x3F); - // Master volume: 0=full(2/2), 1=2/3, 2=2/4, 3=2/5. Numerator 2, - // denominator (master_volume + 2). - let num = 2u32; let den = u32::from(self.master_volume) + 2; - // Raw product range: sample(0..=63) * gain(0..=32) = 0..=2016. - let raw = sample * gain; - // Apply master volume. - let scaled = raw * num / den; - // scaled max (master=full) = 2016 * 2 / 2 = 2016. Center bipolar around - // the mid-DAC (wave centered at 31.5 * gain * master). We output the raw - // level minus its midpoint, scaled to use a good chunk of i16 range. - // Midpoint of sample range is 31.5; approximate the DC bias as - // 32 * gain * num / den (sample == 32). - let mid = 32u32 * gain * num / den; - let centered = scaled as i32 - mid as i32; - // Scale factor 7: peak |centered| ~= 31 * 32 ~= 992 -> *7 ~= 6944 per - // full-gain step; the full-scale (gain 32, master full) reaches - // ~±6.9k, in the APU-square-x2.4 ballpark and well under i16::MAX. + let diff = sample as i32 - 32; + let centered = (diff * gain as i32 * 2) / den as i32; (centered * 7) as i16 } @@ -2281,6 +2265,13 @@ impl Mapper for Fds { } } + fn ppu_peek(&self, addr: u16) -> u8 { + match addr { + 0x0000..=0x1FFF => self.chr_ram[(addr & 0x1FFF) as usize], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { if addr <= 0x1FFF { self.chr_ram[(addr & 0x1FFF) as usize] = value; diff --git a/crates/rustynes-mappers/src/ffe_super_magic_card.rs b/crates/rustynes-mappers/src/ffe_super_magic_card.rs index c4d9ce15..e3a0a413 100644 --- a/crates/rustynes-mappers/src/ffe_super_magic_card.rs +++ b/crates/rustynes-mappers/src/ffe_super_magic_card.rs @@ -547,6 +547,15 @@ impl Mapper for SuperMagicCard { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF if !self.ciram_nametables() => self.chr[self.nt_chr_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/homebrew_boards.rs b/crates/rustynes-mappers/src/homebrew_boards.rs index 9062f6a1..8e1ef9cd 100644 --- a/crates/rustynes-mappers/src/homebrew_boards.rs +++ b/crates/rustynes-mappers/src/homebrew_boards.rs @@ -176,6 +176,15 @@ impl Mapper for Inl31 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -356,6 +365,15 @@ impl Mapper for MagicFloor218 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.ciram[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.ciram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -504,6 +522,15 @@ impl Mapper for Cufrom29 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -595,6 +622,7 @@ pub struct Gtrom111 { prg_bank: u8, chr_bank: u8, nt_bank: u8, + dirty: bool, } /// GTROM save-state layout version. v2 (v2.9.6) grew the nametable pages to @@ -625,6 +653,7 @@ impl Gtrom111 { prg_bank: 0, chr_bank: 0, nt_bank: 0, + dirty: false, }) } @@ -676,6 +705,14 @@ impl Mapper for Gtrom111 { self.flash.copy_from_slice(&self.original); } + fn is_save_data_dirty(&self) -> Option { + Some(self.dirty) + } + + fn clear_save_data_dirty(&mut self) { + self.dirty = false; + } + /// The register is write-only and nothing else lives below `$8000`. fn cpu_read_unmapped(&self, addr: u16) -> bool { addr < 0x8000 @@ -701,7 +738,9 @@ impl Mapper for Gtrom111 { self.update_register(value); } else if addr >= 0x8000 { let a = self.chip_addr(addr); - self.chip.write(&mut self.flash, a, value); + if self.chip.write(&mut self.flash, a, value) { + self.dirty = true; + } } } @@ -714,6 +753,15 @@ impl Mapper for Gtrom111 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.nt_ram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1021,6 +1069,15 @@ impl Mapper for Action53M28 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1193,6 +1250,7 @@ pub struct Unrom512M30 { /// True when the banking latch responds only to $C000-$FFFF and /// $8000-$BFFF is the flash window (submapper 0 w/ battery, or 1/3/4). flash_window: bool, + dirty: bool, } impl Unrom512M30 { @@ -1273,6 +1331,7 @@ impl Unrom512M30 { nametable, bus_conflicts, flash_window, + dirty: false, }) } @@ -1370,6 +1429,18 @@ impl Mapper for Unrom512M30 { } } + fn is_save_data_dirty(&self) -> Option { + if self.flashable() { + Some(self.dirty) + } else { + None + } + } + + fn clear_save_data_dirty(&mut self) { + self.dirty = false; + } + /// Nothing drives `$4020-$7FFF` on any wiring. fn cpu_read_unmapped(&self, addr: u16) -> bool { addr < 0x8000 @@ -1404,7 +1475,9 @@ impl Mapper for Unrom512M30 { self.write_latch(addr, value); } else if self.flashable() { let a = self.prg_chip_addr(self.prg_bank as usize, addr); - self.chip.write(&mut self.prg_rom, a, value); + if self.chip.write(&mut self.prg_rom, a, value) { + self.dirty = true; + } } } else { // Submapper 0 w/o battery or submapper 2: the latch responds to the @@ -1422,6 +1495,15 @@ impl Mapper for Unrom512M30 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/jaleco_discrete.rs b/crates/rustynes-mappers/src/jaleco_discrete.rs index 49b19cb0..954d1045 100644 --- a/crates/rustynes-mappers/src/jaleco_discrete.rs +++ b/crates/rustynes-mappers/src/jaleco_discrete.rs @@ -130,6 +130,19 @@ impl Mapper for Jaleco86 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -258,6 +271,19 @@ impl Mapper for Jaleco140 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -530,6 +556,10 @@ impl Mapper for Jaleco72 { self.inner.ppu_read(addr) } + fn ppu_peek(&self, addr: u16) -> u8 { + self.inner.ppu_read(addr) + } + fn ppu_write(&mut self, addr: u16, value: u8) { self.inner.ppu_write(addr, value); } @@ -587,6 +617,10 @@ impl Mapper for Jaleco92 { self.inner.ppu_read(addr) } + fn ppu_peek(&self, addr: u16) -> u8 { + self.inner.ppu_read(addr) + } + fn ppu_write(&mut self, addr: u16, value: u8) { self.inner.ppu_write(addr, value); } @@ -680,6 +714,19 @@ impl Mapper for Jaleco101 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/kaiser.rs b/crates/rustynes-mappers/src/kaiser.rs index 1872d289..b1291b6b 100644 --- a/crates/rustynes-mappers/src/kaiser.rs +++ b/crates/rustynes-mappers/src/kaiser.rs @@ -457,6 +457,26 @@ impl Mapper for KaiserMapper { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + if self.board == KaiserBoard::M56 { + let slot = (addr as usize) / CHR_BANK_1K; + let count = self.chr_count_1k(); + let bank = (self.chr_banks[slot] as usize) % count; + return self.chr[bank * CHR_BANK_1K + (addr as usize & 0x3FF)]; + } + self.chr[addr as usize & (self.chr.len() - 1)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index 772942b6..a740cd2b 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -399,13 +399,19 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { let mut cursor = header::HEADER_LEN; if h.has_trainer { - if bytes.len() < cursor + header::TRAINER_LEN { + let next = cursor + .checked_add(header::TRAINER_LEN) + .ok_or(RomError::Truncated { + needed: usize::MAX, + got: bytes.len(), + })?; + if bytes.len() < next { return Err(RomError::Truncated { - needed: cursor + header::TRAINER_LEN, + needed: next, got: bytes.len(), }); } - cursor += header::TRAINER_LEN; + cursor = next; } if bytes.len() < cursor + h.prg_size { @@ -627,17 +633,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { // We dispatch by (mapper, submapper) pair. Default mapping below // is conservative; refer to nesdev wiki for the full table. 21 | 23 | 25 => { - // Mapper 21 -> VRC4 only (a/c). - // Mapper 22 -> VRC2a (no submapper). - // Mapper 23 -> VRC2b/VRC4e/VRC4f. - // Mapper 25 -> VRC2c/VRC4b/VRC4d. - // We model VRC4 with CPU-cycle IRQ as the superset; submapper - // selects pin-decoder variant. Banking matches between VRC2 - // and VRC4; the difference is mostly the IRQ counter which - // VRC2 lacks (we just leave it idle). - let m021_vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring) - .map_err(|e| RomError::InvalidConfig(e.to_string()))?; - Box::new(m021_vrc4) + if (h.mapper_id == 23 || h.mapper_id == 25) && h.submapper == 3 { + let vrc2 = Vrc2::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?; + Box::new(vrc2) + } else { + let m021_vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?; + Box::new(m021_vrc4) + } } 22 => { let vrc2 = Vrc2::new(prg_rom, chr_rom, 22, h.submapper, h.mirroring) diff --git a/crates/rustynes-mappers/src/m000_nrom.rs b/crates/rustynes-mappers/src/m000_nrom.rs index 8e6a0c03..bab076d9 100644 --- a/crates/rustynes-mappers/src/m000_nrom.rs +++ b/crates/rustynes-mappers/src/m000_nrom.rs @@ -184,6 +184,21 @@ impl Mapper for Nrom { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => { + let off = self.nametable_offset(addr); + self.vram[off] + } + // $3F00-$3FFF is palette RAM; owned by the PPU, not the mapper. + // Returning 0 here matches the behavior expected of mappers that + // never see palette accesses (PPU short-circuits). + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m001_mmc1.rs b/crates/rustynes-mappers/src/m001_mmc1.rs index 0c466889..7a666fcf 100644 --- a/crates/rustynes-mappers/src/m001_mmc1.rs +++ b/crates/rustynes-mappers/src/m001_mmc1.rs @@ -488,6 +488,21 @@ impl Mapper for Mmc1 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.map_chr(addr); + self.chr[off] + } + 0x2000..=0x3EFF => { + let off = self.nametable_offset(addr); + self.vram[off] + } + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m002_uxrom.rs b/crates/rustynes-mappers/src/m002_uxrom.rs index 5c50eb88..588b78cc 100644 --- a/crates/rustynes-mappers/src/m002_uxrom.rs +++ b/crates/rustynes-mappers/src/m002_uxrom.rs @@ -40,6 +40,8 @@ pub struct UxRom { vram: Box<[u8]>, chr_is_ram: bool, bank: u8, + bank_offset: usize, + last_bank_offset: usize, mirroring: Mirroring, } @@ -75,12 +77,16 @@ impl UxRom { chr_rom.len() ))); }; + let bank_count = (prg_rom.len() / PRG_BANK_16K).max(1); + let last_bank_offset = (bank_count - 1) * PRG_BANK_16K; Ok(Self { prg_rom, chr, vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), chr_is_ram, bank: 0, + bank_offset: 0, + last_bank_offset, mirroring, }) } @@ -91,6 +97,12 @@ impl UxRom { let physical = self.mirroring.physical_bank(table); physical * NAMETABLE_SIZE + local } + #[inline] + fn update_bank_offsets(&mut self) { + let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1); + self.bank_offset = ((self.bank as usize) % bank_count) * PRG_BANK_16K; + self.last_bank_offset = (bank_count - 1) * PRG_BANK_16K; + } } impl Mapper for UxRom { @@ -103,16 +115,12 @@ impl Mapper for UxRom { fn cpu_read(&mut self, addr: u16) -> u8 { match addr { 0x8000..=0xBFFF => { - let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1); - let bank = (self.bank as usize) % bank_count; let off = (addr - 0x8000) as usize; - self.prg_rom[bank * PRG_BANK_16K + off] + self.prg_rom[self.bank_offset + off] } 0xC000..=0xFFFF => { - let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1); - let last = bank_count - 1; let off = (addr - 0xC000) as usize; - self.prg_rom[last * PRG_BANK_16K + off] + self.prg_rom[self.last_bank_offset + off] } _ => 0, } @@ -129,6 +137,7 @@ impl Mapper for UxRom { // the bank register is u8. let mask = u8::try_from((bank_count - 1) | 0x0F).unwrap_or(u8::MAX); self.bank = value & mask; + self.update_bank_offsets(); } } @@ -150,6 +159,15 @@ impl Mapper for UxRom { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -201,6 +219,7 @@ impl Mapper for UxRom { return Err(MapperError::UnsupportedVersion(data[0])); } self.bank = data[1]; + self.update_bank_offsets(); let mut cursor = 2; self.vram .copy_from_slice(&data[cursor..cursor + self.vram.len()]); diff --git a/crates/rustynes-mappers/src/m003_cnrom.rs b/crates/rustynes-mappers/src/m003_cnrom.rs index 7192660d..807cdada 100644 --- a/crates/rustynes-mappers/src/m003_cnrom.rs +++ b/crates/rustynes-mappers/src/m003_cnrom.rs @@ -26,6 +26,7 @@ pub struct CnRom { chr_rom: Box<[u8]>, vram: Box<[u8]>, chr_bank: u8, + chr_bank_offset: usize, mirroring: Mirroring, } @@ -58,6 +59,7 @@ impl CnRom { chr_rom, vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), chr_bank: 0, + chr_bank_offset: 0, mirroring, }) } @@ -69,6 +71,12 @@ impl CnRom { physical * NAMETABLE_SIZE + local } + #[inline] + fn update_bank_offsets(&mut self) { + let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + self.chr_bank_offset = ((self.chr_bank as usize) % bank_count) * CHR_BANK_8K; + } + fn read_prg(&self, addr: u16) -> u8 { let off = (addr - 0x8000) as usize; if self.prg_rom.len() == PRG_BANK_16K { @@ -105,24 +113,28 @@ impl Mapper for CnRom { let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); let mask = u8::try_from((bank_count - 1) | 0x03).unwrap_or(u8::MAX); self.chr_bank = effective & mask; + self.update_bank_offsets(); } } fn chr_phys(&self, addr: u16) -> Option { // CNROM is CHR-ROM only; the same 8 KiB-bank offset `ppu_read` resolves. - let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); - let bank = usize::from(self.chr_bank) % bank_count; - u32::try_from(bank * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok() + u32::try_from(self.chr_bank_offset + usize::from(addr & 0x1FFF)).ok() } fn ppu_read(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { - 0x0000..=0x1FFF => { - let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); - let bank = (self.chr_bank as usize) % bank_count; - self.chr_rom[bank * CHR_BANK_8K + (addr as usize)] - } + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)], 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], _ => 0, } @@ -166,6 +178,7 @@ impl Mapper for CnRom { return Err(MapperError::UnsupportedVersion(data[0])); } self.chr_bank = data[1]; + self.update_bank_offsets(); self.vram.copy_from_slice(&data[2..2 + self.vram.len()]); Ok(()) } diff --git a/crates/rustynes-mappers/src/m004_mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs index 909269a1..c79dae2d 100644 --- a/crates/rustynes-mappers/src/m004_mmc3.rs +++ b/crates/rustynes-mappers/src/m004_mmc3.rs @@ -154,6 +154,8 @@ pub struct Mmc3 { // CHR mode (bit 7 of $8000): 0 = 2K@$0000 + 1K@$1000; // 1 = 1K@$0000 + 2K@$1000. chr_mode: bool, + prg_offsets: [usize; 4], + chr_offsets: [usize; 8], // Mirroring (set via $A000 even). Ignored on 4-screen carts. mirroring: Mirroring, @@ -306,7 +308,7 @@ impl Mmc3 { } else { 2 * NAMETABLE_SIZE }; - Ok(Self { + let mut mapper = Self { prg_rom, chr, prg_ram: vec![0u8; prg_ram_size].into_boxed_slice(), @@ -316,6 +318,8 @@ impl Mmc3 { bank_select: 0, prg_mode: false, chr_mode: false, + prg_offsets: [0; 4], + chr_offsets: [0; 8], mirroring: initial_mirroring, fixed_4screen, prg_ram_enabled: true, @@ -337,7 +341,10 @@ impl Mmc3 { mcacc_prescaler: 0, #[cfg(feature = "mmc3-a12-phase-probe")] probe: Mmc3A12PhaseProbe::default(), - }) + }; + mapper.update_prg_offsets(); + mapper.update_chr_offsets(); + Ok(mapper) } /// Select the board wiring (the NES 2.0 submapper), v2.9.6. An MMC6 gets @@ -436,25 +443,23 @@ impl Mmc3 { self.regs[r & 7] } + fn update_prg_offsets(&mut self) { + let total_banks = (self.prg_rom.len() / PRG_BANK_8K).max(1); + let last = total_banks.saturating_sub(1); + let second_last = total_banks.saturating_sub(2); + let r6 = (self.regs[6] as usize) % total_banks; + let r7 = (self.regs[7] as usize) % total_banks; + self.prg_offsets[0] = (if !self.prg_mode { r6 } else { second_last }) * PRG_BANK_8K; + self.prg_offsets[1] = r7 * PRG_BANK_8K; + self.prg_offsets[2] = (if !self.prg_mode { second_last } else { r6 }) * PRG_BANK_8K; + self.prg_offsets[3] = last * PRG_BANK_8K; + } + /// Resolve a CPU PRG address (`$8000-$FFFF`) to a byte offset in /// `prg_rom`. Implements PRG modes 0 and 1. fn prg_offset(&self, addr: u16) -> usize { - let total_banks = self.prg_rom.len() / PRG_BANK_8K; - let last = total_banks.saturating_sub(1); - let second_last = total_banks.saturating_sub(2); - // R6/R7 are masked to total_banks (typical sizes <= 64 banks => 6 bits). - let r6 = (self.regs[6] as usize) & last; - let r7 = (self.regs[7] as usize) & last; - let bank = match (addr & 0xE000, self.prg_mode) { - (0x8000, false) => r6, - (0x8000, true) => second_last, - (0xA000, _) => r7, - (0xC000, false) => second_last, - (0xC000, true) => r6, - (0xE000, _) => last, - _ => 0, - }; - bank * PRG_BANK_8K + ((addr as usize) & 0x1FFF) + let idx = ((addr & 0xE000) - 0x8000) >> 13; + self.prg_offsets[idx as usize] + ((addr as usize) & 0x1FFF) } /// Resolve a PPU CHR address (`$0000-$1FFF`) to the **raw** 1 KiB CHR @@ -504,20 +509,19 @@ impl Mmc3 { } } + fn update_chr_offsets(&mut self) { + let total_banks_1k = (self.chr.len() / CHR_BANK_1K).max(1); + for slot in 0..8 { + let bank_1k = self.chr_bank_1k_for_slot(slot); + self.chr_offsets[slot] = (bank_1k % total_banks_1k) * CHR_BANK_1K; + } + } + /// Resolve a PPU CHR address (`$0000-$1FFF`) to an offset in `chr`. fn chr_offset(&self, addr: u16) -> usize { let addr = (addr & 0x1FFF) as usize; - let total_banks_1k = self.chr.len() / CHR_BANK_1K; - let mask = total_banks_1k.saturating_sub(1); - // Slot index in 1K units (0..8). let slot = addr / CHR_BANK_1K; - // chr_mode false (0): 2K + 2K + 1K + 1K + 1K + 1K - // R0/R0+1 R1/R1+1 R2 R3 R4 R5 - // chr_mode true (1): 1K + 1K + 1K + 1K + 2K + 2K - // R2 R3 R4 R5 R0/R0+1 R1/R1+1 - let bank_1k = self.chr_bank_1k_for_slot(slot); - let bank = bank_1k & mask; - bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1)) + self.chr_offsets[slot] + (addr & (CHR_BANK_1K - 1)) } /// Compute the CIRAM byte offset for a PPU address in @@ -657,7 +661,7 @@ impl Mapper for Mmc3 { } 0x8000..=0xFFFF => { let off = self.prg_offset(addr); - self.prg_rom[off % self.prg_rom.len()] + self.prg_rom[off] } _ => 0, } @@ -685,8 +689,16 @@ impl Mapper for Mmc3 { if addr & 1 == 0 { // $8000 even: bank-select. self.bank_select = value & 0x07; - self.prg_mode = (value & 0x40) != 0; - self.chr_mode = (value & 0x80) != 0; + let new_prg = (value & 0x40) != 0; + let new_chr = (value & 0x80) != 0; + if self.prg_mode != new_prg { + self.prg_mode = new_prg; + self.update_prg_offsets(); + } + if self.chr_mode != new_chr { + self.chr_mode = new_chr; + self.update_chr_offsets(); + } if self.variant == Mmc3Variant::Mmc6 { // "When PRG RAM is disabled via $8000, the mapper // continuously sets $A001 to $00". @@ -697,7 +709,15 @@ impl Mapper for Mmc3 { } } else { // $8001 odd: bank-data. - self.regs[(self.bank_select & 0x07) as usize] = value; + let reg = (self.bank_select & 0x07) as usize; + if self.regs[reg] != value { + self.regs[reg] = value; + if reg >= 6 { + self.update_prg_offsets(); + } else { + self.update_chr_offsets(); + } + } } } 0xA000..=0xBFFF => { @@ -762,7 +782,19 @@ impl Mapper for Mmc3 { match addr { 0x0000..=0x1FFF => { let off = self.chr_offset(addr); - self.chr[off % self.chr.len()] + self.chr[off] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off] } 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], _ => 0, @@ -1092,6 +1124,8 @@ impl Mapper for Mmc3 { self.mmc6_ram_enabled = data[cur] != 0; self.mmc6_protect = data[cur + 1] & 0xF0; self.mcacc_prescaler = data[cur + 2] & 0x07; + self.update_prg_offsets(); + self.update_chr_offsets(); Ok(()) } } diff --git a/crates/rustynes-mappers/src/m005_mmc5.rs b/crates/rustynes-mappers/src/m005_mmc5.rs index 6de24c5a..b1e70f22 100644 --- a/crates/rustynes-mappers/src/m005_mmc5.rs +++ b/crates/rustynes-mappers/src/m005_mmc5.rs @@ -324,7 +324,7 @@ impl Mmc5Pulse { /// Per-cycle 4-bit output (0..=15). 0 when muted. pub(crate) fn output(&self) -> u8 { - if self.muted() || DUTY_TABLE[self.duty as usize][self.step as usize] == 0 { + if DUTY_TABLE[self.duty as usize][self.step as usize] == 0 || self.muted() { 0 } else { self.envelope_output() @@ -557,6 +557,9 @@ pub struct Mmc5 { in_frame: bool, /// Internal scanline counter; ticks each rendered scanline. scanline_counter: u8, + last_nt_read_addr: u16, + consecutive_nt_reads: u8, + m2_idle_cycles: u8, // === Multiplier === /// `$5205` factor 1. @@ -678,6 +681,9 @@ impl Mmc5 { irq_pending: false, in_frame: false, scanline_counter: 0, + last_nt_read_addr: 0xFFFF, + consecutive_nt_reads: 0, + m2_idle_cycles: 0, mul_a: 0, mul_b: 0, current_mirroring_summary: initial_mirroring, @@ -1290,6 +1296,68 @@ impl Mapper for Mmc5 { } fn ppu_read(&mut self, addr: u16) -> u8 { + self.m2_idle_cycles = 0; + let clean_addr = addr & 0x3FFF; + if (0x2000..=0x2FFF).contains(&clean_addr) { + if self.last_nt_read_addr == clean_addr { + self.consecutive_nt_reads = self.consecutive_nt_reads.saturating_add(1); + if self.consecutive_nt_reads == 3 { + if !self.in_frame { + self.in_frame = true; + self.scanline_counter = 0; + } else { + self.scanline_counter = self.scanline_counter.wrapping_add(1); + } + if self.scanline_counter == self.irq_compare && self.irq_compare != 0 { + self.irq_pending = true; + } + } + } else { + self.last_nt_read_addr = clean_addr; + self.consecutive_nt_reads = 1; + } + } else { + self.consecutive_nt_reads = 0; + self.last_nt_read_addr = 0xFFFF; + } + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr, true); + let len = self.chr.len(); + self.chr[off % len] + } + 0x2000..=0x3EFF => { + // Fill / ExRAM / CIRAM is decided by `$5105`. The lockstep + // bus's PPU calls `peek_nametable` first; this `ppu_read` + // path is only used by the test bus and direct mapper + // probes, so we still service the same logic here. + let (src, local) = self.nt_resolve(addr); + match src { + NtSource::CiramA => self.vram[local], + NtSource::CiramB => self.vram[NAMETABLE_SIZE + local], + NtSource::ExRam => { + if self.exram_is_nametable() { + self.exram[local] + } else { + 0 + } + } + NtSource::Fill => { + if local < 0x03C0 { + self.fill_tile + } else { + let a = self.fill_attr & 0x03; + (a << 6) | (a << 4) | (a << 2) | a + } + } + } + } + _ => 0, + } + } + + fn ppu_peek(&self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { 0x0000..=0x1FFF => { @@ -1536,6 +1604,10 @@ impl Mapper for Mmc5 { } fn notify_cpu_cycle(&mut self) { + self.m2_idle_cycles = self.m2_idle_cycles.saturating_add(1); + if self.m2_idle_cycles >= 3 { + self.in_frame = false; + } // No CPU-cycle IRQ for MMC5. However, the audio extension's two // pulse channels tick their 11-bit timer / duty sequencer every // *other* CPU cycle — same as the 2A03 pulses. Envelope & @@ -1643,6 +1715,9 @@ impl Mapper for Mmc5 { // Vertical blank: clear the in-frame flag (the next rendered line // re-enters "in-frame" via notify_scanline_start). self.in_frame = false; + self.consecutive_nt_reads = 0; + self.last_nt_read_addr = 0xFFFF; + self.m2_idle_cycles = 0; } fn irq_pending(&self) -> bool { diff --git a/crates/rustynes-mappers/src/m007_axrom.rs b/crates/rustynes-mappers/src/m007_axrom.rs index d0e810a4..52b534f6 100644 --- a/crates/rustynes-mappers/src/m007_axrom.rs +++ b/crates/rustynes-mappers/src/m007_axrom.rs @@ -27,6 +27,7 @@ pub struct AxRom { vram: Box<[u8]>, chr_is_ram: bool, bank: u8, + bank_offset: usize, mirroring_b: bool, } @@ -60,6 +61,7 @@ impl AxRom { vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), chr_is_ram, bank: 0, + bank_offset: 0, mirroring_b: false, }) } @@ -69,6 +71,11 @@ impl AxRom { let physical = usize::from(self.mirroring_b); physical * NAMETABLE_SIZE + local } + #[inline] + fn update_bank_offsets(&mut self) { + let bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1); + self.bank_offset = ((self.bank as usize) % bank_count) * PRG_BANK_32K; + } } impl Mapper for AxRom { @@ -80,10 +87,8 @@ impl Mapper for AxRom { fn cpu_read(&mut self, addr: u16) -> u8 { if (0x8000..=0xFFFF).contains(&addr) { - let bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1); - let bank = (self.bank as usize) % bank_count; let off = (addr - 0x8000) as usize; - self.prg_rom[bank * PRG_BANK_32K + off] + self.prg_rom[self.bank_offset + off] } else { 0 } @@ -93,6 +98,7 @@ impl Mapper for AxRom { if (0x8000..=0xFFFF).contains(&addr) { self.bank = value & 0x07; self.mirroring_b = (value & 0x10) != 0; + self.update_bank_offsets(); } } @@ -114,6 +120,15 @@ impl Mapper for AxRom { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -166,6 +181,7 @@ impl Mapper for AxRom { } self.bank = data[1]; self.mirroring_b = data[2] != 0; + self.update_bank_offsets(); let mut cursor = 3; self.vram .copy_from_slice(&data[cursor..cursor + self.vram.len()]); diff --git a/crates/rustynes-mappers/src/m009_mmc2.rs b/crates/rustynes-mappers/src/m009_mmc2.rs index 2ca72d15..4860efef 100644 --- a/crates/rustynes-mappers/src/m009_mmc2.rs +++ b/crates/rustynes-mappers/src/m009_mmc2.rs @@ -137,7 +137,7 @@ impl Mmc2 { bank * PRG_BANK_8K + ((addr as usize) & 0x1FFF) } - fn chr_offset(&mut self, addr: u16) -> usize { + fn chr_offset(&self, addr: u16) -> usize { let addr = (addr & 0x1FFF) as usize; let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1); let bank = if addr < CHR_BANK_4K { @@ -227,6 +227,18 @@ impl Mapper for Mmc2 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m010_mmc4.rs b/crates/rustynes-mappers/src/m010_mmc4.rs index 7a0a771a..2202e7f2 100644 --- a/crates/rustynes-mappers/src/m010_mmc4.rs +++ b/crates/rustynes-mappers/src/m010_mmc4.rs @@ -133,7 +133,7 @@ impl Mmc4 { bank * PRG_BANK_16K + ((addr as usize) & 0x3FFF) } - fn chr_offset(&mut self, addr: u16) -> usize { + fn chr_offset(&self, addr: u16) -> usize { let addr = (addr & 0x1FFF) as usize; let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1); let bank = if addr < CHR_BANK_4K { @@ -239,6 +239,18 @@ impl Mapper for Mmc4 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m011_color_dreams.rs b/crates/rustynes-mappers/src/m011_color_dreams.rs index 6b18e8c3..122e1a6b 100644 --- a/crates/rustynes-mappers/src/m011_color_dreams.rs +++ b/crates/rustynes-mappers/src/m011_color_dreams.rs @@ -145,6 +145,19 @@ impl Mapper for ColorDreams { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let total_8k = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % total_8k; + self.chr_rom[(bank * CHR_BANK_8K + addr as usize) % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m013_cprom.rs b/crates/rustynes-mappers/src/m013_cprom.rs index 448b6a39..6b2e03d7 100644 --- a/crates/rustynes-mappers/src/m013_cprom.rs +++ b/crates/rustynes-mappers/src/m013_cprom.rs @@ -105,6 +105,20 @@ impl Mapper for Cprom { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x0FFF => self.chr_ram[addr as usize], + 0x1000..=0x1FFF => { + let bank = (self.chr_bank as usize) & 0x03; + let off = bank * CHR_BANK_4K + (addr as usize - 0x1000); + self.chr_ram[off % self.chr_ram.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs index 9134d887..36a33ae5 100644 --- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs +++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs @@ -841,6 +841,18 @@ impl Mapper for BandaiFcg { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs b/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs index 8359f7e0..80a7068d 100644 --- a/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs +++ b/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs @@ -340,6 +340,18 @@ impl Mapper for JalecoSs88006 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m019_namco163.rs b/crates/rustynes-mappers/src/m019_namco163.rs index ed156393..5a4798f6 100644 --- a/crates/rustynes-mappers/src/m019_namco163.rs +++ b/crates/rustynes-mappers/src/m019_namco163.rs @@ -168,6 +168,10 @@ pub(crate) struct Namco163Audio { /// 15-cycle prescaler. When it reaches 15, we update the next /// channel and reset. prescaler: u8, + /// Cached individual channel outputs. + channel_outputs: [i16; 8], + /// Running sum of all channel outputs. + cached_sum: i32, } // When the `mapper-audio` feature is OFF, the audio struct still exists @@ -182,6 +186,8 @@ pub(crate) struct Namco163Audio { auto_inc: bool, tick_index: u8, prescaler: u8, + channel_outputs: [i16; 8], + cached_sum: i32, } impl Default for Namco163Audio { @@ -192,6 +198,8 @@ impl Default for Namco163Audio { auto_inc: false, tick_index: 0, prescaler: 0, + channel_outputs: [0; 8], + cached_sum: 0, } } } @@ -212,11 +220,25 @@ impl Namco163Audio { } } + #[cfg(feature = "mapper-audio")] + fn recompute_cache(&mut self) { + self.cached_sum = 0; + for ch in 0..8 { + let out = self.channel_output(ch); + self.channel_outputs[ch as usize] = out; + self.cached_sum += i32::from(out); + } + } + + #[cfg(not(feature = "mapper-audio"))] + fn recompute_cache(&mut self) {} + /// Write to the data port (`$4800-$4FFF`). Stores at the latched /// address; advances the latch when auto-increment is set. pub(crate) fn write_data_port(&mut self, value: u8) { let idx = (self.addr_latch & 0x7F) as usize; self.ram[idx] = value; + self.recompute_cache(); self.step_addr(); } @@ -345,6 +367,11 @@ impl Namco163Audio { phase %= modulus; } self.set_channel_phase(base, phase as u32); + + let old_out = self.channel_outputs[ch as usize]; + let new_out = self.channel_output(ch); + self.channel_outputs[ch as usize] = new_out; + self.cached_sum = self.cached_sum - i32::from(old_out) + i32::from(new_out); } /// Per-channel output sample, bipolar: `(nibble - 8) * volume`, @@ -385,13 +412,9 @@ impl Namco163Audio { if n == 0 { return 0; } - let mut sum: i32 = 0; - for ch in 0..n { - sum += i32::from(self.channel_output(ch)); - } // Per-channel range is -120..=+105; the channel-count-averaged sum has // the same envelope. Scale to the Mesen2 `db_n163` level. - ((sum / i32::from(n)) * NAMCO163_MIX_SCALE) as i16 + ((self.cached_sum / i32::from(n)) * NAMCO163_MIX_SCALE) as i16 } /// Feature-off shim: the wavetable generator does not advance with @@ -445,6 +468,7 @@ impl Namco163Audio { self.auto_inc = src[129] != 0; self.tick_index = src[130]; self.prescaler = src[131]; + self.recompute_cache(); Ok(()) } } @@ -768,6 +792,21 @@ impl Mapper for Namco163 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => match self.chr_target(addr) { + Ok(off) => self.chr_rom[off % self.chr_rom.len()], + Err(ciram) => self.vram[ciram], + }, + 0x2000..=0x3EFF => match self.nt_target(addr) { + Ok(off) => self.chr_rom[off % self.chr_rom.len()], + Err(ciram) => self.vram[ciram], + }, + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m021_vrc4.rs b/crates/rustynes-mappers/src/m021_vrc4.rs index e32a9c24..5c872990 100644 --- a/crates/rustynes-mappers/src/m021_vrc4.rs +++ b/crates/rustynes-mappers/src/m021_vrc4.rs @@ -341,6 +341,18 @@ impl Mapper for Vrc4 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m022_vrc2.rs b/crates/rustynes-mappers/src/m022_vrc2.rs index 991962be..1dbb86b6 100644 --- a/crates/rustynes-mappers/src/m022_vrc2.rs +++ b/crates/rustynes-mappers/src/m022_vrc2.rs @@ -261,11 +261,10 @@ impl Mapper for Vrc2 { match addr & 0xF000 { 0x8000 => self.prg_lo = value & 0x1F, 0x9000 => { - self.mirroring = match value & 0x03 { - 0 => Mirroring::Vertical, - 1 => Mirroring::Horizontal, - 2 => Mirroring::SingleScreenA, - _ => Mirroring::SingleScreenB, + self.mirroring = if (value & 1) == 0 { + Mirroring::Vertical + } else { + Mirroring::Horizontal }; } 0xA000 => self.prg_mid = value & 0x1F, @@ -301,6 +300,18 @@ impl Mapper for Vrc2 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -466,9 +477,9 @@ mod tests { m.cpu_write(0x9000, 1); assert_eq!(m.mirroring, Mirroring::Horizontal); m.cpu_write(0x9000, 2); - assert_eq!(m.mirroring, Mirroring::SingleScreenA); + assert_eq!(m.mirroring, Mirroring::Vertical); m.cpu_write(0x9000, 3); - assert_eq!(m.mirroring, Mirroring::SingleScreenB); + assert_eq!(m.mirroring, Mirroring::Horizontal); } #[test] diff --git a/crates/rustynes-mappers/src/m024_vrc6.rs b/crates/rustynes-mappers/src/m024_vrc6.rs index b09f5480..eb593299 100644 --- a/crates/rustynes-mappers/src/m024_vrc6.rs +++ b/crates/rustynes-mappers/src/m024_vrc6.rs @@ -563,6 +563,18 @@ impl Mapper for Vrc6 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m032_irem_g101.rs b/crates/rustynes-mappers/src/m032_irem_g101.rs index a41f510a..51259a97 100644 --- a/crates/rustynes-mappers/src/m032_irem_g101.rs +++ b/crates/rustynes-mappers/src/m032_irem_g101.rs @@ -211,6 +211,15 @@ impl Mapper for IremG101 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m033_taito_tc0190.rs b/crates/rustynes-mappers/src/m033_taito_tc0190.rs index 9e80853c..079bb636 100644 --- a/crates/rustynes-mappers/src/m033_taito_tc0190.rs +++ b/crates/rustynes-mappers/src/m033_taito_tc0190.rs @@ -190,6 +190,15 @@ impl Mapper for TaitoTc0190 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m034_bnrom_nina001.rs b/crates/rustynes-mappers/src/m034_bnrom_nina001.rs index a2b0fa2c..c84a5828 100644 --- a/crates/rustynes-mappers/src/m034_bnrom_nina001.rs +++ b/crates/rustynes-mappers/src/m034_bnrom_nina001.rs @@ -187,6 +187,27 @@ impl Mapper for M34 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match (addr, self.variant) { + (0x0000..=0x0FFF, M34Variant::Nina001) => { + let total_4k = (self.chr.len() / CHR_BANK_4K).max(1); + let bank = (self.chr_bank_lo as usize) % total_4k; + self.chr[(bank * CHR_BANK_4K + addr as usize) % self.chr.len()] + } + (0x1000..=0x1FFF, M34Variant::Nina001) => { + let total_4k = (self.chr.len() / CHR_BANK_4K).max(1); + let bank = (self.chr_bank_hi as usize) % total_4k; + self.chr[(bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr.len()] + } + (0x0000..=0x1FFF, _) => self.chr[addr as usize % self.chr.len()], + (0x2000..=0x3EFF, _) => { + self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()] + } + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m035_jy_asic.rs b/crates/rustynes-mappers/src/m035_jy_asic.rs index 0f0c73a0..e5e75679 100644 --- a/crates/rustynes-mappers/src/m035_jy_asic.rs +++ b/crates/rustynes-mappers/src/m035_jy_asic.rs @@ -728,6 +728,16 @@ impl Mapper for JyAsic { self.chr[off % self.chr.len()] } + fn ppu_peek(&self, addr: u16) -> u8 { + // The bus masks pattern-table reads to `$0000-$1FFF` before they reach + // here; nametable bytes are routed through `nametable_fetch` / + // `nametable_address` instead (the PPU owns CIRAM), so this only + // services CHR. + let addr = addr & 0x1FFF; + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + fn ppu_write(&mut self, addr: u16, value: u8) { // Pattern-table writes only (CHR-RAM). Nametable writes arrive via // `nametable_write`; the bus never routes `$2000+` to `ppu_write`. diff --git a/crates/rustynes-mappers/src/m036_txc_policeman.rs b/crates/rustynes-mappers/src/m036_txc_policeman.rs index 9ed8d335..07a63739 100644 --- a/crates/rustynes-mappers/src/m036_txc_policeman.rs +++ b/crates/rustynes-mappers/src/m036_txc_policeman.rs @@ -134,6 +134,19 @@ impl Mapper for Txc36 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m038_bitcorp38.rs b/crates/rustynes-mappers/src/m038_bitcorp38.rs index 9e7f627b..20210762 100644 --- a/crates/rustynes-mappers/src/m038_bitcorp38.rs +++ b/crates/rustynes-mappers/src/m038_bitcorp38.rs @@ -125,6 +125,19 @@ impl Mapper for Bitcorp38 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m039_subor39.rs b/crates/rustynes-mappers/src/m039_subor39.rs index aca044b4..26898e80 100644 --- a/crates/rustynes-mappers/src/m039_subor39.rs +++ b/crates/rustynes-mappers/src/m039_subor39.rs @@ -118,6 +118,16 @@ impl Mapper for Subor39 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + // CHR is fixed to bank 0 (8 KiB). + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m041_caltron41.rs b/crates/rustynes-mappers/src/m041_caltron41.rs index 699fb6f1..259ae2b4 100644 --- a/crates/rustynes-mappers/src/m041_caltron41.rs +++ b/crates/rustynes-mappers/src/m041_caltron41.rs @@ -147,6 +147,15 @@ impl Mapper for Caltron41 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs b/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs index 1558aeab..9a607431 100644 --- a/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs +++ b/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs @@ -212,6 +212,22 @@ impl Mapper for Mapper42 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + let count = (self.chr.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m048_taito_tc0690.rs b/crates/rustynes-mappers/src/m048_taito_tc0690.rs index 86797124..eedfdec8 100644 --- a/crates/rustynes-mappers/src/m048_taito_tc0690.rs +++ b/crates/rustynes-mappers/src/m048_taito_tc0690.rs @@ -240,6 +240,15 @@ impl Mapper for TaitoTc0690 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs b/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs index 34156b39..ce6d88c3 100644 --- a/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs +++ b/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs @@ -165,6 +165,15 @@ impl Mapper for Mapper50 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m064_rambo1.rs b/crates/rustynes-mappers/src/m064_rambo1.rs index c3d10af8..7c6ccb1c 100644 --- a/crates/rustynes-mappers/src/m064_rambo1.rs +++ b/crates/rustynes-mappers/src/m064_rambo1.rs @@ -385,6 +385,18 @@ impl Mapper for Rambo1 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m065_irem_h3001.rs b/crates/rustynes-mappers/src/m065_irem_h3001.rs index 5624f2c8..b6a35beb 100644 --- a/crates/rustynes-mappers/src/m065_irem_h3001.rs +++ b/crates/rustynes-mappers/src/m065_irem_h3001.rs @@ -221,6 +221,18 @@ impl Mapper for IremH3001 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m066_gxrom.rs b/crates/rustynes-mappers/src/m066_gxrom.rs index 0f64092b..021754f9 100644 --- a/crates/rustynes-mappers/src/m066_gxrom.rs +++ b/crates/rustynes-mappers/src/m066_gxrom.rs @@ -24,6 +24,8 @@ pub struct GxRom { vram: Box<[u8]>, prg_bank: u8, chr_bank: u8, + prg_bank_offset: usize, + chr_bank_offset: usize, mirroring: Mirroring, } @@ -50,12 +52,18 @@ impl GxRom { chr_rom.len() ))); } + let _prg_bank_count = (prg_rom.len() / PRG_BANK_32K).max(1); + let prg_bank_offset = 0; + let _chr_bank_count = (chr_rom.len() / CHR_BANK_8K).max(1); + let chr_bank_offset = 0; Ok(Self { prg_rom, chr_rom, vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), prg_bank: 0, chr_bank: 0, + prg_bank_offset, + chr_bank_offset, mirroring, }) } @@ -67,11 +75,18 @@ impl GxRom { physical * NAMETABLE_SIZE + local } + #[inline] + fn update_bank_offsets(&mut self) { + let prg_bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1); + self.prg_bank_offset = ((self.prg_bank as usize) % prg_bank_count) * PRG_BANK_32K; + + let chr_bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + self.chr_bank_offset = ((self.chr_bank as usize) % chr_bank_count) * CHR_BANK_8K; + } + fn read_prg(&self, addr: u16) -> u8 { - let bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1); - let bank = (self.prg_bank as usize) % bank_count; let off = (addr - 0x8000) as usize; - self.prg_rom[bank * PRG_BANK_32K + off] + self.prg_rom[self.prg_bank_offset + off] } } @@ -97,17 +112,23 @@ impl Mapper for GxRom { let effective = value & prg_byte; self.prg_bank = (effective >> 4) & 0x03; self.chr_bank = effective & 0x03; + self.update_bank_offsets(); } } fn ppu_read(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { - 0x0000..=0x1FFF => { - let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); - let bank = (self.chr_bank as usize) % bank_count; - self.chr_rom[bank * CHR_BANK_8K + (addr as usize)] - } + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)], 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], _ => 0, } diff --git a/crates/rustynes-mappers/src/m067_sunsoft3.rs b/crates/rustynes-mappers/src/m067_sunsoft3.rs index d2af017a..6d51de1b 100644 --- a/crates/rustynes-mappers/src/m067_sunsoft3.rs +++ b/crates/rustynes-mappers/src/m067_sunsoft3.rs @@ -212,6 +212,18 @@ impl Mapper for Sunsoft3 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m068_sunsoft4.rs b/crates/rustynes-mappers/src/m068_sunsoft4.rs index bff1b0e3..8d5bb392 100644 --- a/crates/rustynes-mappers/src/m068_sunsoft4.rs +++ b/crates/rustynes-mappers/src/m068_sunsoft4.rs @@ -252,6 +252,27 @@ impl Mapper for Sunsoft4 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => { + // CHR-ROM nametable mode is handled by `nametable_fetch` + // (the PPU consults it first). This fallback path serves + // CIRAM for the non-ROM-nametable case. + if self.nt_rom_mode { + self.chr_nt_byte(addr | 0x2000) + } else { + self.vram[self.nametable_offset(addr) % self.vram.len()] + } + } + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs index b35ef679..744b7b1c 100644 --- a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs +++ b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs @@ -520,22 +520,23 @@ impl Sunsoft5BAudio { #[cfg(feature = "mapper-audio")] pub(crate) fn mix(&self) -> i32 { let mut sum: i32 = 0; - for (ch, tone) in [&self.tone_a, &self.tone_b, &self.tone_c] - .iter() - .enumerate() - { - let ch = ch as u8; - // Per wiki: "If both bits are 1 [disable + disable], the - // channel outputs a constant signal at the specified volume. - // If both bits are 0, the result is the logical and of noise - // and tone." Equivalent: emit when (tone_enabled => square - // high) AND (noise_enabled => noise high), defaulting either - // factor to "1" when its source is disabled. - let tone_factor = !self.tone_enabled(ch) || tone.level != 0; - let noise_factor = !self.noise_enabled(ch) || self.noise.level() != 0; - if tone_factor && noise_factor { - sum += self.amplitude(ch); - } + + let tone_factor_a = !self.tone_enabled(0) || self.tone_a.level != 0; + let noise_factor_a = !self.noise_enabled(0) || self.noise.level() != 0; + if tone_factor_a && noise_factor_a { + sum += self.amplitude(0); + } + + let tone_factor_b = !self.tone_enabled(1) || self.tone_b.level != 0; + let noise_factor_b = !self.noise_enabled(1) || self.noise.level() != 0; + if tone_factor_b && noise_factor_b { + sum += self.amplitude(1); + } + + let tone_factor_c = !self.tone_enabled(2) || self.tone_c.level != 0; + let noise_factor_c = !self.noise_enabled(2) || self.noise.level() != 0; + if tone_factor_c && noise_factor_c { + sum += self.amplitude(2); } // Scale the shape table to the hardware-relative level (see // `SUNSOFT5B_MIX_SCALE_NUM`), then centre on zero so the BLEP buffer @@ -862,6 +863,21 @@ impl Mapper for Fme7 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1); + let slot = addr as usize / CHR_BANK_1K; + let bank = (self.chr[slot] as usize) % total_1k; + let off = bank * CHR_BANK_1K + (addr as usize & (CHR_BANK_1K - 1)); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m070_bandai74.rs b/crates/rustynes-mappers/src/m070_bandai74.rs index 3e4c5c0e..ccf53f2d 100644 --- a/crates/rustynes-mappers/src/m070_bandai74.rs +++ b/crates/rustynes-mappers/src/m070_bandai74.rs @@ -147,6 +147,15 @@ impl Mapper for Bandai74 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs index fd742af2..e0b98d64 100644 --- a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs +++ b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs @@ -97,7 +97,8 @@ impl Mapper for Camerica { fn cpu_write(&mut self, addr: u16, value: u8) { match addr { - 0x9000..=0x9FFF if self.has_single_screen => { + 0x9000..=0x9FFF => { + self.has_single_screen = true; self.mirroring = if value & 0x10 == 0 { Mirroring::SingleScreenA } else { @@ -118,6 +119,15 @@ impl Mapper for Camerica { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize % self.chr_ram.len()], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m073_vrc3.rs b/crates/rustynes-mappers/src/m073_vrc3.rs index 53040d50..1f0b6e44 100644 --- a/crates/rustynes-mappers/src/m073_vrc3.rs +++ b/crates/rustynes-mappers/src/m073_vrc3.rs @@ -183,6 +183,15 @@ impl Mapper for Vrc3 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m075_vrc1.rs b/crates/rustynes-mappers/src/m075_vrc1.rs index 39d6fdfa..b6be0786 100644 --- a/crates/rustynes-mappers/src/m075_vrc1.rs +++ b/crates/rustynes-mappers/src/m075_vrc1.rs @@ -170,6 +170,24 @@ impl Mapper for Vrc1 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x0FFF => { + let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1); + let bank = (((self.chr_lo_msb as usize) << 4) | (self.chr_lo as usize)) % total_4k; + self.chr_rom[(bank * CHR_BANK_4K + addr as usize) % self.chr_rom.len()] + } + 0x1000..=0x1FFF => { + let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1); + let bank = (((self.chr_hi_msb as usize) << 4) | (self.chr_hi as usize)) % total_4k; + self.chr_rom[(bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m076_namcot3446.rs b/crates/rustynes-mappers/src/m076_namcot3446.rs index bba55770..1ebc4397 100644 --- a/crates/rustynes-mappers/src/m076_namcot3446.rs +++ b/crates/rustynes-mappers/src/m076_namcot3446.rs @@ -154,6 +154,20 @@ impl Mapper for Namcot3446M76 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let slot = (addr >> 11) as usize & 0x03; + let count = (self.chr_rom.len() / CHR_BANK_2K).max(1); + let bank = (self.chr_banks[slot] as usize) % count; + self.chr_rom[bank * CHR_BANK_2K + (addr as usize & 0x07FF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m077_irem_napoleon.rs b/crates/rustynes-mappers/src/m077_irem_napoleon.rs index 797bbaa4..14140c71 100644 --- a/crates/rustynes-mappers/src/m077_irem_napoleon.rs +++ b/crates/rustynes-mappers/src/m077_irem_napoleon.rs @@ -138,6 +138,21 @@ impl Mapper for Irem77 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x07FF => { + // Bottom 2 KiB: switchable CHR-ROM bank. + let count = (self.chr_rom.len() / CHR_BANK_2K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_2K + addr as usize] + } + 0x0800..=0x1FFF => self.chr_ram[addr as usize - 0x0800], + 0x2000..=0x3EFF => self.nt_ram[Self::nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; // $0000-$07FF is CHR-ROM (read-only); everything else is RAM. diff --git a/crates/rustynes-mappers/src/m078_irem_jaleco78.rs b/crates/rustynes-mappers/src/m078_irem_jaleco78.rs index 0c9e1a9a..456e4f1f 100644 --- a/crates/rustynes-mappers/src/m078_irem_jaleco78.rs +++ b/crates/rustynes-mappers/src/m078_irem_jaleco78.rs @@ -199,6 +199,18 @@ impl Mapper for M78 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs index c23004b6..1bc8b87f 100644 --- a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs +++ b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs @@ -150,6 +150,15 @@ impl Mapper for Nina0379 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m080_taito_x1_005.rs b/crates/rustynes-mappers/src/m080_taito_x1_005.rs index a300630b..f2a981b6 100644 --- a/crates/rustynes-mappers/src/m080_taito_x1_005.rs +++ b/crates/rustynes-mappers/src/m080_taito_x1_005.rs @@ -270,6 +270,15 @@ impl Mapper for TaitoX1005 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m082_taito_x1_017.rs b/crates/rustynes-mappers/src/m082_taito_x1_017.rs index 28e3d764..da638fc3 100644 --- a/crates/rustynes-mappers/src/m082_taito_x1_017.rs +++ b/crates/rustynes-mappers/src/m082_taito_x1_017.rs @@ -254,6 +254,15 @@ impl Mapper for TaitoX1017 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m083_cony.rs b/crates/rustynes-mappers/src/m083_cony.rs index 25a37231..1cab8c95 100644 --- a/crates/rustynes-mappers/src/m083_cony.rs +++ b/crates/rustynes-mappers/src/m083_cony.rs @@ -315,6 +315,15 @@ impl Mapper for Cony83 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m085_vrc7.rs b/crates/rustynes-mappers/src/m085_vrc7.rs index 0da4f89b..772364c1 100644 --- a/crates/rustynes-mappers/src/m085_vrc7.rs +++ b/crates/rustynes-mappers/src/m085_vrc7.rs @@ -465,6 +465,18 @@ impl Mapper for Vrc7 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m087_jaleco87.rs b/crates/rustynes-mappers/src/m087_jaleco87.rs index 8906413c..0c289352 100644 --- a/crates/rustynes-mappers/src/m087_jaleco87.rs +++ b/crates/rustynes-mappers/src/m087_jaleco87.rs @@ -132,6 +132,19 @@ impl Mapper for Jaleco87 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % bank_count; + self.chr_rom[bank * CHR_BANK_8K + (addr as usize)] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m088_namco118.rs b/crates/rustynes-mappers/src/m088_namco118.rs index 386dc4ec..a1c9816b 100644 --- a/crates/rustynes-mappers/src/m088_namco118.rs +++ b/crates/rustynes-mappers/src/m088_namco118.rs @@ -262,6 +262,18 @@ impl Mapper for Namco118 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr[off % self.chr.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m089_sunsoft2.rs b/crates/rustynes-mappers/src/m089_sunsoft2.rs index 1a7b520e..786bb24d 100644 --- a/crates/rustynes-mappers/src/m089_sunsoft2.rs +++ b/crates/rustynes-mappers/src/m089_sunsoft2.rs @@ -165,6 +165,15 @@ impl Mapper for Sunsoft2 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m091_jy_sf3.rs b/crates/rustynes-mappers/src/m091_jy_sf3.rs index 6d2bd89e..8a30a6d8 100644 --- a/crates/rustynes-mappers/src/m091_jy_sf3.rs +++ b/crates/rustynes-mappers/src/m091_jy_sf3.rs @@ -255,6 +255,15 @@ impl Mapper for Jy91 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m093_sunsoft3r.rs b/crates/rustynes-mappers/src/m093_sunsoft3r.rs index 04b826ff..8ebcada7 100644 --- a/crates/rustynes-mappers/src/m093_sunsoft3r.rs +++ b/crates/rustynes-mappers/src/m093_sunsoft3r.rs @@ -147,6 +147,22 @@ impl Mapper for Sunsoft3r { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_ram_enabled { + self.chr_ram[addr as usize] + } else { + // CHR disabled: reads are open bus (0 here). + 0 + } + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m094_un1rom.rs b/crates/rustynes-mappers/src/m094_un1rom.rs index 34289a47..fbadd890 100644 --- a/crates/rustynes-mappers/src/m094_un1rom.rs +++ b/crates/rustynes-mappers/src/m094_un1rom.rs @@ -134,6 +134,15 @@ impl Mapper for Un1rom94 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m095_namcot3425.rs b/crates/rustynes-mappers/src/m095_namcot3425.rs index c5abf2a9..d562e7f7 100644 --- a/crates/rustynes-mappers/src/m095_namcot3425.rs +++ b/crates/rustynes-mappers/src/m095_namcot3425.rs @@ -165,6 +165,15 @@ impl Mapper for Namcot3425M95 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.read_chr(addr), + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { diff --git a/crates/rustynes-mappers/src/m096_bandai96.rs b/crates/rustynes-mappers/src/m096_bandai96.rs index e059a992..c52c6870 100644 --- a/crates/rustynes-mappers/src/m096_bandai96.rs +++ b/crates/rustynes-mappers/src/m096_bandai96.rs @@ -157,6 +157,15 @@ impl Mapper for Bandai96 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let masked = addr & 0x3FFF; + match masked { + 0x0000..=0x1FFF => self.chr[self.chr_offset(masked)], + 0x2000..=0x3EFF => self.vram[nametable_offset(masked, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let masked = addr & 0x3FFF; if (self.last_ppu_addr & 0x3000) != 0x2000 && (masked & 0x3000) == 0x2000 { diff --git a/crates/rustynes-mappers/src/m097_irem_tam_s1.rs b/crates/rustynes-mappers/src/m097_irem_tam_s1.rs index 486ac633..b8138ae7 100644 --- a/crates/rustynes-mappers/src/m097_irem_tam_s1.rs +++ b/crates/rustynes-mappers/src/m097_irem_tam_s1.rs @@ -142,6 +142,15 @@ impl Mapper for Irem97 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m099_vs_system.rs b/crates/rustynes-mappers/src/m099_vs_system.rs index 3abecbe4..5450c0e4 100644 --- a/crates/rustynes-mappers/src/m099_vs_system.rs +++ b/crates/rustynes-mappers/src/m099_vs_system.rs @@ -299,6 +299,15 @@ impl Mapper for VsSystem { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m105_nes_event.rs b/crates/rustynes-mappers/src/m105_nes_event.rs index 61e9aa8e..899a9c8c 100644 --- a/crates/rustynes-mappers/src/m105_nes_event.rs +++ b/crates/rustynes-mappers/src/m105_nes_event.rs @@ -233,6 +233,15 @@ impl Mapper for NesEvent105 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr < 0x2000 { + self.chr_ram[usize::from(addr)] + } else { + self.mmc1.ppu_peek(addr) + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if addr < 0x2000 { diff --git a/crates/rustynes-mappers/src/m107_magic_dragon107.rs b/crates/rustynes-mappers/src/m107_magic_dragon107.rs index bcb49e93..ae678200 100644 --- a/crates/rustynes-mappers/src/m107_magic_dragon107.rs +++ b/crates/rustynes-mappers/src/m107_magic_dragon107.rs @@ -117,6 +117,19 @@ impl Mapper for MagicDragon107 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m113_ave_nina006.rs b/crates/rustynes-mappers/src/m113_ave_nina006.rs index 9043e033..6c49a0c3 100644 --- a/crates/rustynes-mappers/src/m113_ave_nina006.rs +++ b/crates/rustynes-mappers/src/m113_ave_nina006.rs @@ -132,6 +132,15 @@ impl Mapper for Nina006M113 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m116_somari.rs b/crates/rustynes-mappers/src/m116_somari.rs index 09f3a57a..f6539225 100644 --- a/crates/rustynes-mappers/src/m116_somari.rs +++ b/crates/rustynes-mappers/src/m116_somari.rs @@ -302,6 +302,15 @@ impl Mapper for Somari116 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.vram_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m118_txsrom.rs b/crates/rustynes-mappers/src/m118_txsrom.rs index 2ea0a1df..247014dd 100644 --- a/crates/rustynes-mappers/src/m118_txsrom.rs +++ b/crates/rustynes-mappers/src/m118_txsrom.rs @@ -149,6 +149,10 @@ impl Mapper for TxSrom { self.inner.ppu_read(addr) } + fn ppu_peek(&self, addr: u16) -> u8 { + self.inner.ppu_peek(addr) + } + fn ppu_write(&mut self, addr: u16, value: u8) { self.inner.ppu_write(addr, value); } diff --git a/crates/rustynes-mappers/src/m119_tqrom.rs b/crates/rustynes-mappers/src/m119_tqrom.rs index 7dff3cbc..ec5995cf 100644 --- a/crates/rustynes-mappers/src/m119_tqrom.rs +++ b/crates/rustynes-mappers/src/m119_tqrom.rs @@ -147,6 +147,20 @@ impl Mapper for Tqrom { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let a = addr & 0x3FFF; + if a < 0x2000 { + let (is_ram, off) = self.resolve_chr(a); + if is_ram { + self.chr_ram[off % self.chr_ram.len()] + } else { + self.inner.ppu_peek(a) + } + } else { + self.inner.ppu_peek(a) + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let a = addr & 0x3FFF; if a < 0x2000 { diff --git a/crates/rustynes-mappers/src/m132_txc_22211.rs b/crates/rustynes-mappers/src/m132_txc_22211.rs index 65904e7b..7808f7b7 100644 --- a/crates/rustynes-mappers/src/m132_txc_22211.rs +++ b/crates/rustynes-mappers/src/m132_txc_22211.rs @@ -210,6 +210,19 @@ impl Mapper for Txc132 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = ((self.txc.output() & 0x03) as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m136_sachen_3011.rs b/crates/rustynes-mappers/src/m136_sachen_3011.rs index ea2f89c2..e4f6f747 100644 --- a/crates/rustynes-mappers/src/m136_sachen_3011.rs +++ b/crates/rustynes-mappers/src/m136_sachen_3011.rs @@ -244,6 +244,18 @@ impl Mapper for Sachen3011 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let bank = (self.txc.output as usize) % self.chr_count_8k; + self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m151_konami_vs.rs b/crates/rustynes-mappers/src/m151_konami_vs.rs index 34442a4f..cef4c472 100644 --- a/crates/rustynes-mappers/src/m151_konami_vs.rs +++ b/crates/rustynes-mappers/src/m151_konami_vs.rs @@ -66,6 +66,10 @@ impl Mapper for KonamiVs { self.inner.ppu_read(addr) } + fn ppu_peek(&self, addr: u16) -> u8 { + self.inner.ppu_peek(addr) + } + fn ppu_write(&mut self, addr: u16, value: u8) { self.inner.ppu_write(addr, value); } diff --git a/crates/rustynes-mappers/src/m152_bandai152.rs b/crates/rustynes-mappers/src/m152_bandai152.rs index 78635068..0a380658 100644 --- a/crates/rustynes-mappers/src/m152_bandai152.rs +++ b/crates/rustynes-mappers/src/m152_bandai152.rs @@ -150,6 +150,15 @@ impl Mapper for Bandai152 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m156_daou156.rs b/crates/rustynes-mappers/src/m156_daou156.rs index 0c20ea7f..01bfe22b 100644 --- a/crates/rustynes-mappers/src/m156_daou156.rs +++ b/crates/rustynes-mappers/src/m156_daou156.rs @@ -190,6 +190,15 @@ impl Mapper for Daou156 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.read_chr(addr), + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { diff --git a/crates/rustynes-mappers/src/m163_nanjing.rs b/crates/rustynes-mappers/src/m163_nanjing.rs index b6925e3e..6342e0dc 100644 --- a/crates/rustynes-mappers/src/m163_nanjing.rs +++ b/crates/rustynes-mappers/src/m163_nanjing.rs @@ -223,6 +223,15 @@ impl Mapper for Nanjing163 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr < 0x2000 { + self.chr_ram[self.chr_offset(addr)] + } else { + 0 + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if addr < 0x2000 { diff --git a/crates/rustynes-mappers/src/m172_m173_txc.rs b/crates/rustynes-mappers/src/m172_m173_txc.rs index 3f1323a1..aef97132 100644 --- a/crates/rustynes-mappers/src/m172_m173_txc.rs +++ b/crates/rustynes-mappers/src/m172_m173_txc.rs @@ -264,6 +264,15 @@ impl Mapper for Txc172 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -470,6 +479,15 @@ impl Mapper for Txc173 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m176_bmc_fk23c.rs b/crates/rustynes-mappers/src/m176_bmc_fk23c.rs index 33f07c0e..e7f3e0a2 100644 --- a/crates/rustynes-mappers/src/m176_bmc_fk23c.rs +++ b/crates/rustynes-mappers/src/m176_bmc_fk23c.rs @@ -573,6 +573,31 @@ impl Mapper for Fk23c { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + // The FS005 mixed-memory overlay outranks the flat CHR window. + // `select_chr_ram` is `$5xx0.5`, which on submapper 2 selects + // NROM CHR mode rather than the SFC-12B CHR-RAM window, so a + // game can have both set -- and if the flat path won, `ppu_write` + // would keep writing the overlay while `ppu_read` could never + // see it. Resolve the bank first and let the overlay answer. + let slot = (addr as usize) / CHR_BANK_1K; + let b = self.resolve_chr(slot); + if self.chr_bank_is_ram(b) { + return self.chr_ram[b * CHR_BANK_1K + (addr as usize & 0x3FF)]; + } + if self.chr_is_ram || self.select_chr_ram { + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + self.chr[b * CHR_BANK_1K + (addr as usize & 0x3FF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m177_hengedianzi.rs b/crates/rustynes-mappers/src/m177_hengedianzi.rs index 081c2bf7..98246dbe 100644 --- a/crates/rustynes-mappers/src/m177_hengedianzi.rs +++ b/crates/rustynes-mappers/src/m177_hengedianzi.rs @@ -142,6 +142,15 @@ impl Mapper for Hengedianzi177 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m179_hengedianzi.rs b/crates/rustynes-mappers/src/m179_hengedianzi.rs index 43bb03e9..6e1552fe 100644 --- a/crates/rustynes-mappers/src/m179_hengedianzi.rs +++ b/crates/rustynes-mappers/src/m179_hengedianzi.rs @@ -119,6 +119,15 @@ impl Mapper for Hengedianzi179 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m180_nichibutsu180.rs b/crates/rustynes-mappers/src/m180_nichibutsu180.rs index b5c51c38..1d196f9c 100644 --- a/crates/rustynes-mappers/src/m180_nichibutsu180.rs +++ b/crates/rustynes-mappers/src/m180_nichibutsu180.rs @@ -132,6 +132,15 @@ impl Mapper for Nichibutsu180 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m184_sunsoft1.rs b/crates/rustynes-mappers/src/m184_sunsoft1.rs index 49547fcc..e2c5092b 100644 --- a/crates/rustynes-mappers/src/m184_sunsoft1.rs +++ b/crates/rustynes-mappers/src/m184_sunsoft1.rs @@ -136,6 +136,15 @@ impl Mapper for Sunsoft1 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m185_cnrom185.rs b/crates/rustynes-mappers/src/m185_cnrom185.rs index e796e44d..9f616cd0 100644 --- a/crates/rustynes-mappers/src/m185_cnrom185.rs +++ b/crates/rustynes-mappers/src/m185_cnrom185.rs @@ -167,6 +167,25 @@ impl Mapper for CnRom185 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_enabled { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } else { + // CHR disabled by protection: the open bus reads $FF (D0 is + // held high by a pull-up, which $FF already satisfies). + 0xFF + } + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m210_namco175.rs b/crates/rustynes-mappers/src/m210_namco175.rs index 1a91abec..dee80483 100644 --- a/crates/rustynes-mappers/src/m210_namco175.rs +++ b/crates/rustynes-mappers/src/m210_namco175.rs @@ -232,6 +232,18 @@ impl Mapper for Namco175 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let off = self.chr_offset(addr); + self.chr_rom[off % self.chr_rom.len()] + } + 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m228_action52.rs b/crates/rustynes-mappers/src/m228_action52.rs index 75ef31df..d7bba962 100644 --- a/crates/rustynes-mappers/src/m228_action52.rs +++ b/crates/rustynes-mappers/src/m228_action52.rs @@ -186,6 +186,15 @@ impl Mapper for Action52M228 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[self.chr_bank() * CHR_BANK_8K + usize::from(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m230_reset_multicart.rs b/crates/rustynes-mappers/src/m230_reset_multicart.rs index 363b64cf..f6fbe1ae 100644 --- a/crates/rustynes-mappers/src/m230_reset_multicart.rs +++ b/crates/rustynes-mappers/src/m230_reset_multicart.rs @@ -163,6 +163,15 @@ impl Mapper for ResetMulticart230 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs index b22fdac9..fb6d0dee 100644 --- a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs +++ b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs @@ -127,6 +127,15 @@ impl Mapper for Camerica232 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m235_golden_game.rs b/crates/rustynes-mappers/src/m235_golden_game.rs index 5efdf7fd..e5eb4754 100644 --- a/crates/rustynes-mappers/src/m235_golden_game.rs +++ b/crates/rustynes-mappers/src/m235_golden_game.rs @@ -165,6 +165,15 @@ impl Mapper for GoldenGame235 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)], + 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m240_cne_multicart.rs b/crates/rustynes-mappers/src/m240_cne_multicart.rs index 1352cc74..f87c8496 100644 --- a/crates/rustynes-mappers/src/m240_cne_multicart.rs +++ b/crates/rustynes-mappers/src/m240_cne_multicart.rs @@ -128,6 +128,19 @@ impl Mapper for Cne240 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m241_bxrom241.rs b/crates/rustynes-mappers/src/m241_bxrom241.rs index 712c1e34..cd573599 100644 --- a/crates/rustynes-mappers/src/m241_bxrom241.rs +++ b/crates/rustynes-mappers/src/m241_bxrom241.rs @@ -153,6 +153,15 @@ impl Mapper for Bxrom241 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m244_cne_decathlon.rs b/crates/rustynes-mappers/src/m244_cne_decathlon.rs index 76a314c5..b5a35245 100644 --- a/crates/rustynes-mappers/src/m244_cne_decathlon.rs +++ b/crates/rustynes-mappers/src/m244_cne_decathlon.rs @@ -151,6 +151,19 @@ impl Mapper for Decathlon244 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { diff --git a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs index e40d758e..4b7f5adc 100644 --- a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs +++ b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs @@ -162,6 +162,20 @@ impl Mapper for FongShenBang246 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let slot = (addr >> 11) as usize & 0x03; + let count = (self.chr_rom.len() / CHR_BANK_2K).max(1); + let bank = (self.chr_banks[slot] as usize) % count; + self.chr_rom[bank * CHR_BANK_2K + (addr as usize & 0x07FF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/m250_nitra250.rs b/crates/rustynes-mappers/src/m250_nitra250.rs index 52be1e27..58ca00e6 100644 --- a/crates/rustynes-mappers/src/m250_nitra250.rs +++ b/crates/rustynes-mappers/src/m250_nitra250.rs @@ -220,6 +220,15 @@ impl Mapper for Nitra250 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.read_chr(addr), + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { diff --git a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs index f2ec20a1..34f12c2e 100644 --- a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs +++ b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs @@ -116,6 +116,16 @@ impl Mapper for Waixing252 { self.inner.ppu_read(addr) } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr < 0x2000 + && let Some(off) = self.ram_offset(addr) + { + return self.chr_ram[off]; + } + self.inner.ppu_peek(addr) + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if addr < 0x2000 { diff --git a/crates/rustynes-mappers/src/m268_bmc_coolboy.rs b/crates/rustynes-mappers/src/m268_bmc_coolboy.rs index 3fc9b378..9d914671 100644 --- a/crates/rustynes-mappers/src/m268_bmc_coolboy.rs +++ b/crates/rustynes-mappers/src/m268_bmc_coolboy.rs @@ -335,6 +335,22 @@ impl Mapper for Coolboy { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + let slot = (addr as usize) / CHR_BANK_1K; + let b = self.resolve_chr(slot); + self.chr[b * CHR_BANK_1K + (addr as usize & 0x3FF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/m513_sachen_9602.rs b/crates/rustynes-mappers/src/m513_sachen_9602.rs index bc70cf7f..806b57c6 100644 --- a/crates/rustynes-mappers/src/m513_sachen_9602.rs +++ b/crates/rustynes-mappers/src/m513_sachen_9602.rs @@ -237,6 +237,15 @@ impl Mapper for Sachen9602 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize & (CHR_BANK_8K - 1)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 2cc58b60..502d1b06 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -57,7 +57,7 @@ pub struct BgSplitState { /// Mappers override [`Mapper::debug_info`] to surface their banking /// registers and any IRQ-counter state. Fields are pre-formatted strings /// so the UI doesn't have to know the protocol of each mapper. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct MapperDebugInfo { /// Mapper id (e.g. `4` for MMC3). pub mapper_id: u16, @@ -307,6 +307,11 @@ pub trait Mapper: Send { /// + nametable mirror window). Used as the BG-side / generic fetch path. fn ppu_read(&mut self, addr: u16) -> u8; + /// Read a PPU byte without side-effects for inspection/debugging. + fn ppu_peek(&self, _addr: u16) -> u8 { + 0 + } + /// Read a byte from the PPU pattern-table window (`$0000-$1FFF`) on /// behalf of a *sprite* tile fetch. MMC5 in 8x16 sprite mode uses a /// separate set of CHR bank registers (`$5120-$5127`) for sprite @@ -705,6 +710,14 @@ pub trait Mapper: Send { self.save_data_mut().fill(0); } + /// Check dirty + fn is_save_data_dirty(&self) -> Option { + None + } + + /// Clear dirty + fn clear_save_data_dirty(&mut self) {} + /// Start recording the diagnostic FDS read-stream trace (off by default; /// observation-only). No-op for non-FDS mappers. See [`crate::FdsTraceRec`]. fn enable_fds_trace(&mut self) {} diff --git a/crates/rustynes-mappers/src/mmc3_boards.rs b/crates/rustynes-mappers/src/mmc3_boards.rs index c6e4f192..ee468571 100644 --- a/crates/rustynes-mappers/src/mmc3_boards.rs +++ b/crates/rustynes-mappers/src/mmc3_boards.rs @@ -761,6 +761,15 @@ impl Mapper for Mmc3Board { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr < 0x2000 { + self.read_chr(addr) + } else { + self.core.ppu_peek(addr) + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if addr >= 0x2000 { diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext.rs b/crates/rustynes-mappers/src/mmc3_boards_ext.rs index e4cbc275..b05114da 100644 --- a/crates/rustynes-mappers/src/mmc3_boards_ext.rs +++ b/crates/rustynes-mappers/src/mmc3_boards_ext.rs @@ -675,6 +675,17 @@ impl Mapper for Mmc3BoardExt { value } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + if addr >= 0x2000 { + return self.core.ppu_peek(addr); + } + match self.chr_target(addr) { + Chr::Rom(off) => self.chr[off], + Chr::Ram(off) => self.chr_ram[off], + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if addr >= 0x2000 { diff --git a/crates/rustynes-mappers/src/mmc3_clones.rs b/crates/rustynes-mappers/src/mmc3_clones.rs index 050578a1..1ea14063 100644 --- a/crates/rustynes-mappers/src/mmc3_clones.rs +++ b/crates/rustynes-mappers/src/mmc3_clones.rs @@ -646,6 +646,27 @@ impl Mapper for Mmc3CloneMapper { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + if matches!(self.board, CloneBoard::M245) { + let half = if self.core.chr_mode { 0x1000 } else { 0 }; + let off = (half ^ (addr as usize & 0x1FFF)) & (self.chr.len() - 1); + return self.chr[off]; + } + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + let slot = (addr as usize) / CHR_BANK_1K; + let bank = self.resolve_chr(slot); + self.chr[bank * CHR_BANK_1K + (addr as usize & 0x3FF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.core.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/multicart_discrete.rs b/crates/rustynes-mappers/src/multicart_discrete.rs index 7b571d28..2b651646 100644 --- a/crates/rustynes-mappers/src/multicart_discrete.rs +++ b/crates/rustynes-mappers/src/multicart_discrete.rs @@ -259,6 +259,15 @@ impl Mapper for Multicart15 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -438,6 +447,15 @@ impl Mapper for Multicart61 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -602,6 +620,19 @@ impl Mapper for Multicart62 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -736,6 +767,19 @@ impl Mapper for Multicart200 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr.len() / CHR_BANK_8K).max(1); + let bank = (self.bank as usize) % count; + self.chr[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -900,6 +944,15 @@ impl Mapper for Multicart201 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1091,6 +1144,15 @@ impl Mapper for Multicart202 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1266,6 +1328,15 @@ impl Mapper for Multicart203 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1459,6 +1530,15 @@ impl Mapper for Multicart212 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1631,6 +1711,15 @@ impl Mapper for Multicart213 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1799,6 +1888,15 @@ impl Mapper for Multicart214 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1959,6 +2057,19 @@ impl Mapper for Multicart58 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -2092,6 +2203,19 @@ impl Mapper for Multicart60 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -2218,6 +2342,15 @@ impl Mapper for Multicart231 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -2393,6 +2526,19 @@ impl Mapper for Maxi15M234 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank() as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -2563,6 +2709,19 @@ impl Mapper for Multicart225 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -2727,6 +2886,15 @@ impl Mapper for Multicart226 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -2936,6 +3104,15 @@ impl Mapper for Multicart227 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -3113,6 +3290,19 @@ impl Mapper for Multicart229 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -3269,6 +3459,15 @@ impl Mapper for Multicart233 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -3633,6 +3832,22 @@ impl Mapper for DiscreteMapper { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + let count = (self.chr.len() / CHR_BANK_8K).max(1); + let bank = self.chr_8k_bank() % count; + self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -3870,6 +4085,21 @@ impl Mapper for Bmc204 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (CHR_BANK_8K - 1)]; + } + let bank = self.chr8 % self.chr_count_8k(); + self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -4056,6 +4286,21 @@ impl Mapper for Bmc11160 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (CHR_BANK_8K - 1)]; + } + let bank = self.chr8 % self.chr_count_8k(); + self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/nsf.rs b/crates/rustynes-mappers/src/nsf.rs index f5c35723..71e1c2e5 100644 --- a/crates/rustynes-mappers/src/nsf.rs +++ b/crates/rustynes-mappers/src/nsf.rs @@ -767,6 +767,11 @@ impl Mapper for NsfMapper { 0 } + fn ppu_peek(&self, _addr: u16) -> u8 { + // No CHR: NSF files carry no graphics. Reads return open-bus-ish 0. + 0 + } + fn ppu_write(&mut self, _addr: u16, _value: u8) {} fn current_mirroring(&self) -> Mirroring { diff --git a/crates/rustynes-mappers/src/ntdec.rs b/crates/rustynes-mappers/src/ntdec.rs index 799eb621..e41d4e51 100644 --- a/crates/rustynes-mappers/src/ntdec.rs +++ b/crates/rustynes-mappers/src/ntdec.rs @@ -172,6 +172,15 @@ impl Mapper for Ntdec63 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -329,6 +338,19 @@ impl Mapper for Ntdec174 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -469,6 +491,15 @@ impl Mapper for Ntdec2722M40 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -640,6 +671,19 @@ impl Mapper for Ntdec81 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { @@ -790,6 +834,15 @@ impl Mapper for NtdecAsder112 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.read_chr(addr), + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { @@ -967,6 +1020,23 @@ impl Mapper for NtdecTc112 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (CHR_BANK_8K - 1)]; + } + let slot = (addr as usize) / CHR_BANK_2K; + let count = self.chr_count_2k(); + let bank = self.chr2[slot] % count; + self.chr[bank * CHR_BANK_2K + (addr as usize & (CHR_BANK_2K - 1))] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1175,6 +1245,15 @@ impl Mapper for NtdecN625092 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr[addr as usize & (CHR_BANK_8K - 1)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/sachen_8259.rs b/crates/rustynes-mappers/src/sachen_8259.rs index af87ceb7..81c9e3f6 100644 --- a/crates/rustynes-mappers/src/sachen_8259.rs +++ b/crates/rustynes-mappers/src/sachen_8259.rs @@ -182,6 +182,15 @@ impl Mapper for Sachen8259M137 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.read_chr(addr), + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if (0x2000..=0x3EFF).contains(&addr) { @@ -390,6 +399,23 @@ impl Mapper for Sachen8259 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + if self.chr_is_ram { + return self.chr[addr as usize & (self.chr.len() - 1)]; + } + let slot = (addr as usize) / CHR_2K; + let count = (self.chr.len() / CHR_2K).max(1); + let bank = self.chr_bank(slot) % count; + self.chr[bank * CHR_2K + (addr as usize & (CHR_2K - 1))] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mappers/src/sachen_discrete.rs b/crates/rustynes-mappers/src/sachen_discrete.rs index db1d7cd7..6da5cfad 100644 --- a/crates/rustynes-mappers/src/sachen_discrete.rs +++ b/crates/rustynes-mappers/src/sachen_discrete.rs @@ -143,6 +143,19 @@ impl Mapper for Sachen133 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -273,6 +286,19 @@ impl Mapper for Sachen145 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -408,6 +434,19 @@ impl Mapper for Sachen146 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -641,6 +680,15 @@ impl Mapper for Sachen3018M147 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -823,6 +871,15 @@ impl Mapper for Sachen148 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -982,6 +1039,19 @@ impl Mapper for Sachen149 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = (self.chr_bank as usize) % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { @@ -1254,6 +1324,15 @@ impl Mapper for Sachen150 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)], + 0x2000..=0x3EFF => self.vram[self.resolve_nametable(addr)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -1465,6 +1544,19 @@ impl Mapper for SachenTca01M143 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let count = (self.chr_rom.len() / CHR_BANK_8K).max(1); + let bank = 0 % count; + self.chr_rom[bank * CHR_BANK_8K + addr as usize] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; if let 0x2000..=0x3EFF = addr { diff --git a/crates/rustynes-mappers/src/waixing.rs b/crates/rustynes-mappers/src/waixing.rs index 0d013111..49a16589 100644 --- a/crates/rustynes-mappers/src/waixing.rs +++ b/crates/rustynes-mappers/src/waixing.rs @@ -189,6 +189,15 @@ impl Mapper for Waixing242 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -362,6 +371,15 @@ impl Mapper for WaixingFs304M162 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -559,6 +577,15 @@ impl Mapper for Waixing178 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => self.chr_ram[addr as usize], + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { @@ -782,6 +809,26 @@ impl Mapper for Waixing253 { } } + fn ppu_peek(&self, addr: u16) -> u8 { + let addr = addr & 0x3FFF; + match addr { + 0x0000..=0x1FFF => { + let slot = (addr as usize) / CHR_BANK_1K; + let lo = self.chr_low[slot]; + if (lo == 4 || lo == 5) && !self.force_chr_rom { + let page = (lo as usize & 0x01) * CHR_BANK_1K; + return self.chr_ram + [(page + (addr as usize & 0x3FF)) & (self.chr_ram.len() - 1)]; + } + let page = + (lo as usize | ((self.chr_high[slot] as usize) << 8)) % self.chr_count_1k; + self.chr[page * CHR_BANK_1K + (addr as usize & 0x3FF)] + } + 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)], + _ => 0, + } + } + fn ppu_write(&mut self, addr: u16, value: u8) { let addr = addr & 0x3FFF; match addr { diff --git a/crates/rustynes-mobile/Cargo.toml b/crates/rustynes-mobile/Cargo.toml index 0efa62ea..287279c3 100644 --- a/crates/rustynes-mobile/Cargo.toml +++ b/crates/rustynes-mobile/Cargo.toml @@ -72,6 +72,7 @@ uniffi = { version = "0.32", features = ["cli"] } # same convenience the desktop has. `deflate` is the only codec real NES zips use; # default features off keeps bzip2/zstd/aes out of the mobile binary. zip = { version = "8", default-features = false, features = ["deflate"] } +getrandom = "0.2" [dev-dependencies] # v1.8.7 Phase B — the room-code loopback test stands up an in-process WebSocket diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index abf1b144..31d59e13 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -1,3 +1,5 @@ +#![allow(clippy::needless_borrow)] +#![allow(clippy::items_after_statements)] //! `rustynes-mobile` — the platform-agnostic mobile control surface over //! [`rustynes_core`]. //! @@ -3023,9 +3025,7 @@ fn composite_hd_locked(g: &mut Inner) -> Vec { return Vec::new(); } // Snapshot the per-pixel tile source, the CHR (0x0000..0x2000), and the frame. - let hd_tiles = g.nes.hd_tile_source().to_vec(); - let framebuffer = g.nes.framebuffer().to_vec(); - let mut chr = vec![0u8; 0x2000]; + let mut chr = [0u8; 0x2000]; for (addr, slot) in (0u16..0x2000).zip(chr.iter_mut()) { *slot = g.nes.peek_ppu(addr); } @@ -3049,6 +3049,8 @@ fn composite_hd_locked(g: &mut Inner) -> Vec { }; #[cfg(test)] injected_frame_fault(); + let hd_tiles = g.nes.hd_tile_source(); + let framebuffer = g.nes.framebuffer(); comp.composite(&framebuffer, &hd_tiles, &watched, |addr| { chr.get((addr & 0x1FFF) as usize).copied().unwrap_or(0) }) @@ -3157,6 +3159,10 @@ fn nat_phase_detail(phase: &NatPhase) -> String { /// input ⇒ byte-identical state) is untouched: the core still cold-boots /// deterministically when the session promotes to `InGame`. fn nondeterministic_seed() -> u64 { + let mut buf = [0u8; 8]; + if getrandom::getrandom(&mut buf).is_ok() { + return u64::from_le_bytes(buf); + } use std::time::{SystemTime, UNIX_EPOCH}; // Truncating the 128-bit nanosecond count to 64 bits is intentional — this is // a PRNG seed, not a timestamp, so the discarded high bits don't matter. diff --git a/crates/rustynes-netplay/Cargo.toml b/crates/rustynes-netplay/Cargo.toml index 1726d434..040136c7 100644 --- a/crates/rustynes-netplay/Cargo.toml +++ b/crates/rustynes-netplay/Cargo.toml @@ -31,6 +31,7 @@ signaling-server = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "d [dependencies] rustynes-core.workspace = true thiserror = { workspace = true, features = ["std"] } +getrandom = "0.2" # v2.6.0 — signaling-server-only async + JSON deps (native, opt-in). The pure # room/relay protocol (the `signaling` module) needs none of these; they back diff --git a/crates/rustynes-netplay/src/connection.rs b/crates/rustynes-netplay/src/connection.rs index 24dba460..538c83d8 100644 --- a/crates/rustynes-netplay/src/connection.rs +++ b/crates/rustynes-netplay/src/connection.rs @@ -328,6 +328,7 @@ impl Transport for UdpTransport { fn poll(&mut self) -> Vec { self.poll_with_source() .into_iter() + .filter(|(_msg, from)| self.remote.is_none_or(|peer| peer == *from)) .map(|(msg, _from)| msg) .collect() } @@ -800,7 +801,8 @@ impl NetplayConnection { NetMessage::Input { .. } | NetMessage::InputAck { .. } | NetMessage::Checksum { .. } - | NetMessage::Roster { .. } => {} + | NetMessage::Roster { .. } + | NetMessage::InputBatch { .. } => {} } } diff --git a/crates/rustynes-netplay/src/mesh_net.rs b/crates/rustynes-netplay/src/mesh_net.rs index 1e07ea21..d4f818b8 100644 --- a/crates/rustynes-netplay/src/mesh_net.rs +++ b/crates/rustynes-netplay/src/mesh_net.rs @@ -140,10 +140,15 @@ impl Transport for UdpMeshTransport { let mut buf = [0u8; RECV_BUF_LEN]; for _ in 0..MAX_DATAGRAMS_PER_POLL { match self.socket.recv_from(&mut buf) { - Ok((len, _from)) => match NetMessage::from_bytes(&buf[..len]) { - Some(msg) => out.push(msg), - None => self.dropped_invalid = self.dropped_invalid.saturating_add(1), - }, + Ok((len, from)) => { + if !self.peers.contains(&from) { + continue; + } + match NetMessage::from_bytes(&buf[..len]) { + Some(msg) => out.push(msg), + None => self.dropped_invalid = self.dropped_invalid.saturating_add(1), + } + } Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, // Windows can surface a prior send's ICMP unreachable as a // ConnectionReset on the next recv — not fatal, keep draining. @@ -403,6 +408,9 @@ impl MeshHost { self.last_roster_sent = Some(now); self.roster_resends -= 1; } + if self.roster_resends > 0 { + return Ok(None); + } // After the first broadcast, build + return the host's transport. // (We still want the joiners to have received it; the resends above // ride out loss, but the host can proceed immediately — its mesh @@ -618,11 +626,12 @@ mod tests { let s0 = UdpSocket::bind(loopback()).unwrap(); let s1 = UdpSocket::bind(loopback()).unwrap(); let s2 = UdpSocket::bind(loopback()).unwrap(); + let a0 = s0.local_addr().unwrap(); let a1 = s1.local_addr().unwrap(); let a2 = s2.local_addr().unwrap(); let mut t0 = UdpMeshTransport::new(s0, vec![a1, a2]).unwrap(); - let mut t1 = UdpMeshTransport::new(s1, vec![]).unwrap(); - let mut t2 = UdpMeshTransport::new(s2, vec![]).unwrap(); + let mut t1 = UdpMeshTransport::new(s1, vec![a0]).unwrap(); + let mut t2 = UdpMeshTransport::new(s2, vec![a0]).unwrap(); let msg = NetMessage::Input { player: 0, @@ -639,20 +648,26 @@ mod tests { #[test] fn mesh_transport_drops_malformed() { - let s0 = UdpSocket::bind(loopback()).unwrap(); let s1 = UdpSocket::bind(loopback()).unwrap(); let a1 = s1.local_addr().unwrap(); - let _t0 = UdpMeshTransport::new(s0, vec![a1]).unwrap(); - let mut t1 = UdpMeshTransport::new(s1, vec![]).unwrap(); + let valid_peer = UdpSocket::bind(loopback()).unwrap(); + let a0 = valid_peer.local_addr().unwrap(); + let mut t1 = UdpMeshTransport::new(s1, vec![a0]).unwrap(); - // Blast junk at t1 directly from a stray socket. + // Blast junk at t1 directly from a valid peer (to test invalid payloads) + // AND from a stray socket (to test invalid sources). let stray = UdpSocket::bind(loopback()).unwrap(); - stray.send_to(&[], a1).unwrap(); - stray.send_to(&[250, 1, 2], a1).unwrap(); - stray + + valid_peer.send_to(&[], a1).unwrap(); + valid_peer.send_to(&[250, 1, 2], a1).unwrap(); + valid_peer .send_to(&NetMessage::InputAck { frame: 9 }.to_bytes(), a1) .unwrap(); + stray + .send_to(&NetMessage::InputAck { frame: 10 }.to_bytes(), a1) + .unwrap(); + let got = drain(&mut t1, 1); assert_eq!(got, vec![NetMessage::InputAck { frame: 9 }]); assert!(t1.dropped_invalid() >= 2); @@ -819,8 +834,11 @@ mod tests { let listen = host.local_addr().unwrap(); let mut j = MeshJoiner::connect(loopback(), listen, 1, id).unwrap(); let mut done = false; + let mut j_done = false; for _ in 0..500 { - let _ = j.pump(); + if !j_done && let Ok(Some(_)) = j.pump() { + j_done = true; + } if host.pump().expect("host pump").is_some() { done = true; break; diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs index 0357043b..7be76769 100644 --- a/crates/rustynes-netplay/src/message.rs +++ b/crates/rustynes-netplay/src/message.rs @@ -253,6 +253,7 @@ pub enum NetMessage { /// The 0-based player index this input belongs to (0..=3). Lets a /// peer route a remote input to the correct controller port; in a /// mesh topology every peer's input carries its own player index. + /// Player index player: u8, /// The frame this input applies to. frame: u32, @@ -260,6 +261,16 @@ pub enum NetMessage { input: u8, }, + /// Batched sequence of consecutive inputs for `player` starting at `start_frame`. + InputBatch { + /// Player index + player: u8, + /// Start frame + start_frame: u32, + /// Inputs array + inputs: Vec, + }, + /// Acknowledges receipt of the remote's input up to `frame`. Lets the /// sender stop retransmitting already-confirmed inputs (Stage 2 uses /// this for the sliding-window retransmit cutoff). @@ -366,6 +377,7 @@ impl NetMessage { const TAG_CHECKSUM: u8 = 3; const TAG_QUALITY: u8 = 4; const TAG_ROSTER: u8 = 5; + const TAG_INPUT_BATCH: u8 = 6; // IP-family tags inside a `Roster` entry's address encoding. const IP_V4: u8 = 4; @@ -396,6 +408,17 @@ impl NetMessage { out.extend_from_slice(&frame.to_le_bytes()); out.push(input); } + Self::InputBatch { + player, + start_frame, + ref inputs, + } => { + out.push(Self::TAG_INPUT_BATCH); + out.push(player); + out.extend_from_slice(&start_frame.to_le_bytes()); + out.push(u8::try_from(inputs.len().min(u8::MAX as usize)).unwrap_or(u8::MAX)); + out.extend_from_slice(inputs); + } Self::InputAck { frame } => { out.push(Self::TAG_INPUT_ACK); out.extend_from_slice(&frame.to_le_bytes()); diff --git a/crates/rustynes-netplay/src/relay.rs b/crates/rustynes-netplay/src/relay.rs index a5574838..abf9bca6 100644 --- a/crates/rustynes-netplay/src/relay.rs +++ b/crates/rustynes-netplay/src/relay.rs @@ -341,10 +341,12 @@ impl TurnClient { fn next_tx(&mut self) -> TransactionId { let mut tx = [0u8; 12]; - for chunk in tx.chunks_mut(4) { - let r = self.rng.next_u64().to_le_bytes(); - let n = chunk.len(); - chunk.copy_from_slice(&r[..n]); + if getrandom::getrandom(&mut tx).is_err() { + for chunk in tx.chunks_mut(4) { + let r = self.rng.next_u64().to_le_bytes(); + let n = chunk.len(); + chunk.copy_from_slice(&r[..n]); + } } tx } diff --git a/crates/rustynes-netplay/src/session.rs b/crates/rustynes-netplay/src/session.rs index 0a0a049c..12e82061 100644 --- a/crates/rustynes-netplay/src/session.rs +++ b/crates/rustynes-netplay/src/session.rs @@ -637,13 +637,43 @@ impl RollbackSession { }) } + /// Fold a single remote input into the history, returning the frame it mispredicted (if any). + fn ingest_single_input(&mut self, player: u8, frame: u32, input: u8) -> Option { + let lp = self.local_player(); + // Ignore an out-of-range or self-addressed player index + if player >= self.config.num_players || player == lp { + return None; + } + // Drop a frame past the lookahead window before it can size an allocation. + if frame > self.max_accepted_frame() { + return None; + } + self.ensure_frame(frame); + let slot = &mut self.history[frame as usize]; + let cell = &mut slot.players[player as usize]; + let prev = cell.input; + let already_simulated = slot.simulated; + cell.input = input; + cell.confirmed = true; + // Misprediction: a frame we already ran used a value that + // the just-arrived real input contradicts. Re-running it + // (and everything after) is required. A second copy of an + // identical input (idempotent resend, whether previously a + // prediction or already confirmed) leaves `prev == input`, + // so it is correctly NOT treated as a misprediction. + if already_simulated && prev != input { + Some(frame) + } else { + None + } + } + /// Poll the transport and fold in every message. Returns the earliest /// frame (if any) whose previously-used prediction was contradicted by a /// newly-arrived real remote input. fn ingest(&mut self, nes: &Nes) -> Result, NetplayError> { let mut earliest_mispredict: Option = None; let messages = self.transport.poll(); - let lp = self.local_player(); for msg in messages { match msg { @@ -668,34 +698,23 @@ impl RollbackSession { frame, input, } => { - // Ignore an out-of-range or self-addressed player index - // (a malformed / foreign packet must never corrupt our own - // authored input or index out of bounds). - if player >= self.config.num_players || player == lp { - continue; - } - // Drop a frame past the lookahead window before it can size - // an allocation (AUD-06; see MAX_SESSION_FRAME_LOOKAHEAD). - // A genuine input is resent until acked, so nothing is lost. - if frame > self.max_accepted_frame() { - continue; - } - self.ensure_frame(frame); - let slot = &mut self.history[frame as usize]; - let cell = &mut slot.players[player as usize]; - let prev = cell.input; - let already_simulated = slot.simulated; - cell.input = input; - cell.confirmed = true; - // Misprediction: a frame we already ran used a value that - // the just-arrived real input contradicts. Re-running it - // (and everything after) is required. A second copy of an - // identical input (idempotent resend, whether previously a - // prediction or already confirmed) leaves `prev == input`, - // so it is correctly NOT treated as a misprediction. - if already_simulated && prev != input { + if let Some(mispredict) = self.ingest_single_input(player, frame, input) { earliest_mispredict = - Some(earliest_mispredict.map_or(frame, |e| e.min(frame))); + Some(earliest_mispredict.map_or(mispredict, |e| e.min(mispredict))); + } + } + NetMessage::InputBatch { + player, + start_frame, + inputs, + } => { + for (offset, input) in inputs.into_iter().enumerate() { + #[allow(clippy::cast_possible_truncation)] + let frame = start_frame.saturating_add(offset as u32); + if let Some(mispredict) = self.ingest_single_input(player, frame, input) { + earliest_mispredict = + Some(earliest_mispredict.map_or(mispredict, |e| e.min(mispredict))); + } } } NetMessage::InputAck { frame } => { @@ -980,6 +999,10 @@ impl RollbackSession { // First un-acked frame (the remote has everything <= remote_ack_frame). let first_unacked = self.remote_ack_frame.map_or(0, |a| a.saturating_add(1)); let start = first_unacked.max(newest.saturating_sub(INPUT_RESEND_WINDOW)); + if start > newest { + return; + } + let mut batched = Vec::with_capacity((newest - start + 1) as usize); for frame in start..=newest { let idx = frame as usize; if idx >= self.history.len() { @@ -988,13 +1011,18 @@ impl RollbackSession { let slot = self.history[idx].players[lp_idx]; // Only resend frames we actually authored (confirmed-local). if slot.confirmed { - self.transport.send(&NetMessage::Input { - player: lp, - frame, - input: slot.input, - }); + batched.push(slot.input); + } else { + break; } } + if !batched.is_empty() { + self.transport.send(&NetMessage::InputBatch { + player: lp, + start_frame: start, + inputs: batched, + }); + } } /// Send a `Checksum` for every confirmed checksum-boundary frame whose diff --git a/crates/rustynes-netplay/src/spectator.rs b/crates/rustynes-netplay/src/spectator.rs index 8679f495..16ad77dd 100644 --- a/crates/rustynes-netplay/src/spectator.rs +++ b/crates/rustynes-netplay/src/spectator.rs @@ -471,7 +471,8 @@ impl SpectatorSession { // quality hints (it never stalls the players). NetMessage::InputAck { .. } | NetMessage::Checksum { .. } - | NetMessage::Quality { .. } => {} + | NetMessage::Quality { .. } + | NetMessage::InputBatch { .. } => {} } } } diff --git a/crates/rustynes-ppu/src/ppu.rs b/crates/rustynes-ppu/src/ppu.rs index 75bd2047..93764046 100644 --- a/crates/rustynes-ppu/src/ppu.rs +++ b/crates/rustynes-ppu/src/ppu.rs @@ -459,6 +459,9 @@ pub struct Ppu { // === CPU-facing register state === pub(crate) ctrl: PpuCtrl, pub(crate) mask: PpuMask, + /// Cached emphasis LUT offset `(emph << 6)` for fast indexing into [`Self::rgba_lut`]. + /// Maintained by [`Self::update_emph_offset`] on reset, `$2001` writes, and snapshot restore. + pub(crate) cached_emph_offset: usize, /// Two-stage delay pipeline of `mask` consumed exclusively by the /// pre-render dot-339 odd-frame skip check. `mask_for_skip_check` is /// the value seen *this* dot; `mask_skip_pipe1` is the staged value @@ -1512,6 +1515,7 @@ impl Ppu { region, ctrl: PpuCtrl::empty(), mask: PpuMask::empty(), + cached_emph_offset: 0, mask_for_skip_check: PpuMask::empty(), dot0_replaced: false, mask_skip_pipe1: PpuMask::empty(), @@ -2124,6 +2128,15 @@ impl Ppu { &mut self.ciram } + pub(crate) const fn update_emph_offset(&mut self) { + let raw = (self.mask.bits() >> 5) & 0x07; + let emph = match self.region { + PpuRegion::Ntsc => raw, + _ => (raw & 0b100) | ((raw & 0b001) << 1) | ((raw & 0b010) >> 1), + }; + self.cached_emph_offset = (emph as usize) << 6; + } + /// Performs a soft-reset of the PPU (warm boot). Per `docs/ppu-2c02.md`: /// - PPUCTRL := 0 /// - PPUMASK := 0 @@ -2136,6 +2149,7 @@ impl Ppu { pub const fn reset(&mut self) { self.ctrl = PpuCtrl::empty(); self.mask = PpuMask::empty(); + self.update_emph_offset(); self.mask_for_skip_check = PpuMask::empty(); self.mask_skip_pipe1 = PpuMask::empty(); self.prev_rendering_enabled = false; @@ -3127,6 +3141,7 @@ impl Ppu { } let was_rendering = self.mask.rendering_enabled(); self.mask = PpuMask::from_bits_truncate(value); + self.update_emph_offset(); self.arm_oam_corruption_disable(was_rendering); // v2.0 Phase 6 (mc-ppu-subpos): arm the analog `$2001` BG-reload // delay. `self.mask` (and so the sprite-eval / shift / pixel @@ -3627,8 +3642,7 @@ impl Ppu { // no scanline-241 VBL set, no pre-render clear, no OAM-corruption // edge, no PPUDATA state machine in flight, no `$2006` copy-V or // PPUMASK write delay pending, rendering stably enabled. This gate - // detects that regime cheaply and, when the (default-OFF) runtime - // knob is on, dispatches to [`Self::tick_visible_render_fast`], which + // detects that regime cheaply and dispatches to [`Self::tick_visible_render_fast`], which // runs the *identical* helper sequence with the dead branches pruned. // // BYTE-IDENTITY: the fast handler is byte-identical BY CONSTRUCTION — @@ -5111,9 +5125,9 @@ impl Ppu { let mut hd_sprites: [usize; 4] = [0; 4]; #[cfg(feature = "hd-pack")] let mut hd_spr_n: usize = 0; - if self.mask.contains(PpuMask::SHOW_SPRITE) - && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) - { + let sprites_active = self.mask.contains(PpuMask::SHOW_SPRITE) + && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)); + if sprites_active { for i in 0..self.spr_count as usize { // v2.0 (ppu-sprite-shifter-counter): a sprite emits when its // X-counter is 0 OR it is in the persistent halted state. The @@ -5162,11 +5176,7 @@ impl Ppu { // none of the eight hardware sprites is opaque (they have the higher // OAM indexes, so the lower priority). Never sprite 0, so never a hit. // `spr_extra_count` is 0 unless the option is on. - if spr_idx == 0 - && self.spr_extra_count != 0 - && self.mask.contains(PpuMask::SHOW_SPRITE) - && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) - { + if spr_idx == 0 && self.spr_extra_count != 0 && sprites_active { for e in 0..usize::from(self.spr_extra_count) { let off = pixel_x.wrapping_sub(u16::from(self.spr_extra_x[e])); if off >= 8 { @@ -5238,7 +5248,8 @@ impl Ppu { let final_idx = self.read_palette(pal_addr) & 0x3F; // Write RGBA8 to framebuffer. - let off = ((pixel_y as usize) * 256 + pixel_x as usize) * 4; + let px = (pixel_y as usize) * 256 + pixel_x as usize; + let off = px * 4; // v2.8.0 Phase 4 — route through the precomputed // `(emphasis << 6) | color` lookup (built from the same pure // `palette_color_to_rgba`, so byte-identical to the old per-pixel @@ -5250,13 +5261,7 @@ impl Ppu { // and GREEN on the PAL 2C07 and the Dendy, bit 6 the reverse (NESdev // "Colour emphasis"), so those two exchange off NTSC. The region is // fixed per console, so the branch is constant. - let raw = (self.mask.bits() >> 5) & 0x07; - let emph = usize::from(if matches!(self.region, PpuRegion::Ntsc) { - raw - } else { - (raw & 0b100) | ((raw & 0b001) << 1) | ((raw & 0b010) >> 1) - }); - let lut_idx = (emph << 6) | usize::from(final_idx); + let lut_idx = self.cached_emph_offset | usize::from(final_idx); let rgba = self.rgba_lut[lut_idx]; self.framebuffer[off..off + 4].copy_from_slice(&rgba); // Parallel palette-index output for the `NES_NTSC` composite filter @@ -5271,7 +5276,7 @@ impl Ppu { // samples simply redistribute. Not worth the correctness hazard of // gating a buffer the NTSC filter, the mobile API, `fast_dotloop_diff` // and a unit test all read. See `docs/performance.md`. - self.index_framebuffer[off >> 2] = lut_idx as u16; + self.index_framebuffer[px] = lut_idx as u16; // v1.2.0 C3 (hd-pack): record the CHR tile that produced this pixel, // mirroring the BG-vs-sprite priority decision above. Output-only; this @@ -5648,7 +5653,9 @@ impl Ppu { // Secondary-OAM clear (cycles 1-64): the bus carries $FF and the // parallel secondary OAM is filled with $FF, 1 byte per 2 dots. self.oam_bus_copybuffer = 0xFF; - self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF; + if (cycle & 1) == 0 { + self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF; + } return; } if cycle <= 256 { @@ -7076,6 +7083,7 @@ mod tests { let mut p = Ppu::new(region); p.post_reset_mask_remaining = 0; p.mask = mask; // rendering off: the pixel is the backdrop + p.update_emph_offset(); p.palette_ram[palette_index(0x3F00)] = 0x21; p.scanline = 10; p.dot = 20; diff --git a/crates/rustynes-ppu/src/snapshot.rs b/crates/rustynes-ppu/src/snapshot.rs index 5d845a6e..7ac34105 100644 --- a/crates/rustynes-ppu/src/snapshot.rs +++ b/crates/rustynes-ppu/src/snapshot.rs @@ -910,6 +910,7 @@ impl Ppu { self.cached_idle_line = false; } self.flags_cached_scanline = i16::MIN; + self.update_emph_offset(); // sanity: the schema-fixed sizes mean we should be at end of input now. if r.pos != data.len() { diff --git a/crates/rustynes-script/src/piccolo_backend.rs b/crates/rustynes-script/src/piccolo_backend.rs index 660423f4..b04570fc 100644 --- a/crates/rustynes-script/src/piccolo_backend.rs +++ b/crates/rustynes-script/src/piccolo_backend.rs @@ -698,22 +698,24 @@ impl PiccoloBackend { /// `Ok(())` on completion, [`ScriptError::Budget`] if the fuel ran out, or /// [`ScriptError::Lua`] if the script raised. fn drive(&mut self, executor: &piccolo::StashedExecutor) -> Result<(), ScriptError> { + const MAX_HEAP_BYTES: usize = 64 * 1024 * 1024; // 64 MiB sandbox limit let fuel_budget = fuel_for(self.budget.get()); - let finished = self.lua.enter(|ctx| { + let res = self.lua.enter(|ctx| { let ex = ctx.fetch(executor); let mut fuel = Fuel::with(fuel_budget); loop { - // `step` returns `true` when the executor can make no more - // progress (finished or errored). if ex.step(ctx, &mut fuel) { - break true; + break Ok(true); + } + if ctx.gc_metrics().total_allocation() > MAX_HEAP_BYTES { + break Err(ScriptError::Lua("memory limit exceeded".into())); } if !fuel.should_continue() { - break false; + break Ok(false); } } }); - if finished { + if res? { self.lua .execute::<()>(executor) .map_err(|e| ScriptError::Lua(e.to_string())) diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index e9e61775..dc15c023 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -100,6 +100,10 @@ const CHIPS: &[Chip] = &[ snapshot_src: include_str!("../../rustynes-ppu/src/snapshot.rs"), writer_fns: &[], derived_or_config: &[ + ( + "cached_emph_offset", + "derived: reconstructed during load_state based on current region settings", + ), ( "oam_decay_enabled", "config: the opt-in decay model's enable flag, re-applied by the host on load \ diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index c191cf87..bafa50dc 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -333,13 +333,22 @@ final class AppModel: ObservableObject { audioSession.configure() let data = try await library.romData(for: entry) // v2.9.7: the stored FDS BIOS rides along (a cartridge or NSF ignores it). - let core = try EmulatorCore(romData: data, fdsBios: FdsBiosStore.load()) + var core = try EmulatorCore(romData: data, fdsBios: FdsBiosStore.load()) // v2.9.9 (re-audit NF-21): key this game by the core's identity. An // entry from before v2.9.9 is keyed by the whole file's hash; move it, // then its saves, states, RA progress and overrides, once. The library // goes first: when it cannot move, the old key stays for this session // and nothing else moves, so the stores never split between two keys. let entry = await migrateKey(entry, to: core.romIdentity) + + let fdsUrl = AppModel.fdsDiskUrl(forSha: entry.sha) + if FileManager.default.fileExists(atPath: fdsUrl.path) { + if let diskBytes = try? Data(contentsOf: fdsUrl), diskBytes.count == data.count { + if let reloaded = try? EmulatorCore(romData: diskBytes, fdsBios: FdsBiosStore.load()) { + core = reloaded + } + } + } core.isMuted = muted // v2.9.2 (AUD-14): pace the gamepad turbo pulse by emulated frames. // Captures the manager (a plain class), not `self`, so the closure @@ -922,6 +931,11 @@ final class AppModel: ObservableObject { /// Compares the save RAM once a second and writes it on a change. private var batteryTimer: Timer? + static func fdsDiskUrl(forSha sha: String) -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("RustyNES/fds/disk_\\(sha).fds") + } + /// Bind `core`'s battery RAM to its `.sav` and load it. Returns a message /// when an existing save could not be used (that session is then not saved, /// so the file is never overwritten). @@ -958,6 +972,16 @@ final class AppModel: ObservableObject { /// Write the running game's battery save now, and wait for it. private func flushBatteryNow() { + if let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty() { + let url = AppModel.fdsDiskUrl(forSha: sha) + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try core.diskImageBytes().write(to: url, options: .atomic) + core.clearDiskDirty() + } catch { + NSLog("RustyNES: FDS disk save failed: \\(error)") + } + } guard let saver = battery, let core = emulator else { return } saver.flushNow(core.batteryRam()) } @@ -967,6 +991,18 @@ final class AppModel: ObservableObject { /// is a few KiB, so it runs synchronously within the task rather than handing /// a mutable task id across threads. private func flushBatteryInBackground() { + if let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty() { + let task = BackgroundTask(name: "RustyNES FDS save") + let url = AppModel.fdsDiskUrl(forSha: sha) + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try core.diskImageBytes().write(to: url, options: .atomic) + core.clearDiskDirty() + } catch { + NSLog("RustyNES: FDS disk save failed: \\(error)") + } + task.end() + } guard let saver = battery, let core = emulator else { return } let task = BackgroundTask(name: "RustyNES battery save") defer { task.end() } @@ -1034,7 +1070,9 @@ final class AppModel: ObservableObject { /// interruption is in effect; otherwise pause. (We deliberately declare NO /// background-audio mode, so backgrounding pauses.) private func applyRunState() { - if sceneActive, !menuPaused, !audioInterrupted, !routePaused { + let isRunning = sceneActive && !menuPaused && !audioInterrupted && !routePaused + UIApplication.shared.isIdleTimerDisabled = isRunning + if isRunning { emulator?.resume() } else { emulator?.pause() diff --git a/ios/RustyNES/RomIdentity.swift b/ios/RustyNES/RomIdentity.swift index fa5d9d68..65ab622b 100644 --- a/ios/RustyNES/RomIdentity.swift +++ b/ios/RustyNES/RomIdentity.swift @@ -122,6 +122,10 @@ enum RomKeyMigration { root.appendingPathComponent("battery/\(legacy).sav"), root.appendingPathComponent("battery/\(identity).sav") )) + outcome.add(moveFile( + root.appendingPathComponent("fds/disk_\(legacy).fds"), + root.appendingPathComponent("fds/disk_\(identity).fds") + )) outcome.add(moveDir( root.appendingPathComponent("states/\(legacy)", isDirectory: true), root.appendingPathComponent("states/\(identity)", isDirectory: true) From 9c8539ae8dc79888a1ec949c5a4942c0bc667aae Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 22:49:17 -0400 Subject: [PATCH 031/104] fix(mappers): revert the MMC5 nametable-snoop IRQ detector from the import The codex_sec import (bb66f0af, Round 2 item MAP-R2-07) moved MMC5's scanline detection into `Mmc5::ppu_read`: it counted three consecutive reads of the same nametable address as a new scanline, the way the real chip snoops the PPU bus, and dropped `in_frame` after three idle M2 cycles. In this emulator that path is never reached in real play. The lockstep `SystemBus` fetches nametables through `peek_nametable`, not `ppu_read`, so the detector never saw a fetch, `scanline_counter` never reached `irq_compare`, and the scanline IRQ stopped firing altogether. The existing detection (`notify_scanline_start` / `notify_vblank`) was still there but had been left as the only path the bus did not use for the counter. Evidence: the commercial oracle (`external_real_games`, mmc5 filter) went red on 3 titles -- Castlevania III, Uchuu Keibitai SDF and Bandit Kings of Ancient China -- and is 3/3 green after this revert; the mappers lib's 75 mmc5 tests pass either way. The `mmc5` test-ROM gate (3 tests) did NOT catch the break: none of those ROMs depends on the scanline IRQ. That was a coverage gap, closed here. The revert removes the three snoop fields (`last_nt_read_addr`, `consecutive_nt_reads`, `m2_idle_cycles`) with their init, the `notify_cpu_cycle` and `notify_vblank` lines, and makes `ppu_read` delegate to `ppu_peek`, which holds the same CHR / fill / ExRAM / CIRAM resolution `ppu_read` had before the import. Kept from the import: the pulse `output()` reorder (byte-identical) and the side-effect-free `ppu_peek`. Against the pre-import tree (c922ed40) the file now differs only in those two. New gate: `nes::tests::mmc5_scanline_irq_fires_through_the_real_bus`, a synthetic 32 KiB MMC5 image that waits out the PPU warm-up, enables rendering, arms the IRQ at line 20 (inhibiting the APU frame IRQ, which would otherwise flood the handler) and counts IRQs in RAM over 10 frames. Mutation-checked: with the import's m005_mmc5.rs restored it counts 0 and fails; with this revert it passes. Full-suite context for the import as a whole (`--features test-roms,commercial-roms`, release, with this revert in the tree): 3,631 passed, 1 failed, 16 ignored. The single failure is the coverage sweep (30 of 815): 3 FDS audio hashes (the import's FDS output rounding, reverted next), 25 dumps staged on 2026-10-08/09 for the sibling boards with no blessed baseline yet, and 2 malformed PD CPROM dumps. No other output moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/nes.rs | 71 +++++++++++++++++++++ crates/rustynes-mappers/src/m005_mmc5.rs | 79 +++--------------------- 2 files changed, 78 insertions(+), 72 deletions(-) diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index c54ecb17..199fdc37 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -3553,6 +3553,77 @@ mod tests { bytes } + /// Synthetic MMC5 (mapper 5) image, 32 KiB PRG + 8 KiB CHR. MMC5 powers on + /// in PRG mode 3 with the last 8 KiB bank at `$E000`, so the program lives + /// there. It waits two vblanks (past the NES PPU warm-up, where a + /// `$2001` write is ignored), turns rendering on, arms the scanline IRQ + /// at line 20 (`$5203` / `$5204`) and idles with interrupts enabled. The + /// IRQ handler counts into `$0000` and acknowledges by reading `$5204`. + fn synth_mmc5_scanline_irq() -> Vec { + let mut bytes = Vec::with_capacity(16 + 32 * 1024 + 8 * 1024); + bytes.extend_from_slice(b"NES\x1A"); + bytes.push(2); // 2 x 16 KiB PRG + bytes.push(1); // 1 x 8 KiB CHR + bytes.push(0x50); // flags6: mapper low nibble 5 + bytes.push(0); // flags7 + bytes.extend_from_slice(&[0u8; 8]); + let mut prg = vec![0u8; 32 * 1024]; + let base = 0x6000; // $E000 within the last 8 KiB bank + #[rustfmt::skip] + let reset: [u8; 35] = [ + 0x78, // $E000 SEI + 0xA2, 0xFF, // $E001 LDX #$FF + 0x9A, // $E003 TXS + 0xA9, 0x40, // $E004 LDA #$40 + 0x8D, 0x17, 0x40, // $E006 STA $4017 (inhibit the APU frame IRQ) + 0x2C, 0x02, 0x20, // $E009 BIT $2002 + 0x10, 0xFB, // $E00C BPL $E009 + 0x2C, 0x02, 0x20, // $E00E BIT $2002 + 0x10, 0xFB, // $E011 BPL $E00E + 0xA9, 0x1E, // $E013 LDA #$1E + 0x8D, 0x01, 0x20, // $E015 STA $2001 (rendering on) + 0xA9, 0x14, // $E018 LDA #20 + 0x8D, 0x03, 0x52, // $E01A STA $5203 (compare = line 20) + 0xA9, 0x80, // $E01D LDA #$80 + 0x8D, 0x04, 0x52, // $E01F STA $5204 (IRQ enable) + 0x58, // $E022 CLI + ]; + prg[base..base + reset.len()].copy_from_slice(&reset); + // $E023: JMP $E023 (idle) + prg[base + 0x23..base + 0x26].copy_from_slice(&[0x4C, 0x23, 0xE0]); + // $E030: IRQ handler -- INC $00 ; LDA $5204 (ack) ; RTI + prg[base + 0x30..base + 0x36].copy_from_slice(&[0xE6, 0x00, 0xAD, 0x04, 0x52, 0x40]); + // $E040: NMI handler -- RTI (NMI is never enabled; defensive) + prg[base + 0x40] = 0x40; + let len = prg.len(); + prg[len - 6..].copy_from_slice(&[0x40, 0xE0, 0x00, 0xE0, 0x30, 0xE0]); + bytes.extend_from_slice(&prg); + bytes.extend_from_slice(&[0u8; 8 * 1024]); + bytes + } + + /// The MMC5 scanline IRQ fires through the real `SystemBus`, once per + /// rendered frame. Regression for a v3.2.0 import that moved the + /// scanline detector into `Mmc5::ppu_read` (a nametable-snoop model): + /// the lockstep bus fetches nametables through `peek_nametable`, never + /// `ppu_read`, so the detector never saw a fetch and the IRQ stopped + /// firing in real play (*Castlevania III*, *Uchuu Keibitai SDF* and + /// *Bandit Kings* all went red). The `mmc5` test-ROM gate did not catch + /// it, because none of those ROMs depends on the scanline IRQ. + #[test] + fn mmc5_scanline_irq_fires_through_the_real_bus() { + let mut nes = Nes::from_rom(&synth_mmc5_scanline_irq()).unwrap(); + for _ in 0..10 { + nes.run_frame(); + } + let count = nes.peek(0x0000); + // Rendering is on from about frame 2; one IRQ per frame after that. + assert!( + (5..=10).contains(&count), + "expected one MMC5 scanline IRQ per rendered frame, counted {count}" + ); + } + /// `Nes::scan_barcode` validates at the boundary, refuses a cartridge /// with no reader, and a scan is part of the save state: a snapshot taken /// mid-swipe restores mid-swipe and the two runs stay identical. diff --git a/crates/rustynes-mappers/src/m005_mmc5.rs b/crates/rustynes-mappers/src/m005_mmc5.rs index b1e70f22..9c106ea0 100644 --- a/crates/rustynes-mappers/src/m005_mmc5.rs +++ b/crates/rustynes-mappers/src/m005_mmc5.rs @@ -557,9 +557,6 @@ pub struct Mmc5 { in_frame: bool, /// Internal scanline counter; ticks each rendered scanline. scanline_counter: u8, - last_nt_read_addr: u16, - consecutive_nt_reads: u8, - m2_idle_cycles: u8, // === Multiplier === /// `$5205` factor 1. @@ -681,9 +678,6 @@ impl Mmc5 { irq_pending: false, in_frame: false, scanline_counter: 0, - last_nt_read_addr: 0xFFFF, - consecutive_nt_reads: 0, - m2_idle_cycles: 0, mul_a: 0, mul_b: 0, current_mirroring_summary: initial_mirroring, @@ -1296,65 +1290,13 @@ impl Mapper for Mmc5 { } fn ppu_read(&mut self, addr: u16) -> u8 { - self.m2_idle_cycles = 0; - let clean_addr = addr & 0x3FFF; - if (0x2000..=0x2FFF).contains(&clean_addr) { - if self.last_nt_read_addr == clean_addr { - self.consecutive_nt_reads = self.consecutive_nt_reads.saturating_add(1); - if self.consecutive_nt_reads == 3 { - if !self.in_frame { - self.in_frame = true; - self.scanline_counter = 0; - } else { - self.scanline_counter = self.scanline_counter.wrapping_add(1); - } - if self.scanline_counter == self.irq_compare && self.irq_compare != 0 { - self.irq_pending = true; - } - } - } else { - self.last_nt_read_addr = clean_addr; - self.consecutive_nt_reads = 1; - } - } else { - self.consecutive_nt_reads = 0; - self.last_nt_read_addr = 0xFFFF; - } - let addr = addr & 0x3FFF; - match addr { - 0x0000..=0x1FFF => { - let off = self.chr_offset(addr, true); - let len = self.chr.len(); - self.chr[off % len] - } - 0x2000..=0x3EFF => { - // Fill / ExRAM / CIRAM is decided by `$5105`. The lockstep - // bus's PPU calls `peek_nametable` first; this `ppu_read` - // path is only used by the test bus and direct mapper - // probes, so we still service the same logic here. - let (src, local) = self.nt_resolve(addr); - match src { - NtSource::CiramA => self.vram[local], - NtSource::CiramB => self.vram[NAMETABLE_SIZE + local], - NtSource::ExRam => { - if self.exram_is_nametable() { - self.exram[local] - } else { - 0 - } - } - NtSource::Fill => { - if local < 0x03C0 { - self.fill_tile - } else { - let a = self.fill_attr & 0x03; - (a << 6) | (a << 4) | (a << 2) | a - } - } - } - } - _ => 0, - } + // MMC5 CHR / nametable reads have no side effect here: its scanline + // detection runs from `notify_scanline_start` / `notify_vblank`. A + // v3.2.0 import moved a nametable-snoop detector into this method, + // but the lockstep bus never routes nametable fetches through + // `ppu_read` (they go via `peek_nametable`), so the IRQ stopped + // firing in real play; reverted (`external_mmc5_*` caught it). + self.ppu_peek(addr) } fn ppu_peek(&self, addr: u16) -> u8 { @@ -1604,10 +1546,6 @@ impl Mapper for Mmc5 { } fn notify_cpu_cycle(&mut self) { - self.m2_idle_cycles = self.m2_idle_cycles.saturating_add(1); - if self.m2_idle_cycles >= 3 { - self.in_frame = false; - } // No CPU-cycle IRQ for MMC5. However, the audio extension's two // pulse channels tick their 11-bit timer / duty sequencer every // *other* CPU cycle — same as the 2A03 pulses. Envelope & @@ -1715,9 +1653,6 @@ impl Mapper for Mmc5 { // Vertical blank: clear the in-frame flag (the next rendered line // re-enters "in-frame" via notify_scanline_start). self.in_frame = false; - self.consecutive_nt_reads = 0; - self.last_nt_read_addr = 0xFFFF; - self.m2_idle_cycles = 0; } fn irq_pending(&self) -> bool { From 97fbc115a55e96110d99fc5bbf0ca98888532d80 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 22:52:57 -0400 Subject: [PATCH 032/104] fix(mappers): restore the FDS output rounding the import changed Round 2 item MAP-R2-03 in the codex_sec import (bb66f0af) rewrote `FdsAudio::output` as a single expression, `(sample - 32) * gain * 2 / den`, on the reasoning that it is the same arithmetic with fewer operations. It is not the same arithmetic. The original floors the level (`sample * gain * 2 / den`) and the DC midpoint (`32 * gain * 2 / den`) separately and subtracts them; the rewrite divides once and truncates toward zero. Whenever the master-volume denominator is 3 or 5 the two disagree by one step before the x7 scale. Worked case, master 2/3, gain 1, sample 63: separate floors give 42 - 21 = 21 (147), the combined form gives 31 * 2 / 3 = 20 (140). That is an output change, not an optimisation, and it moved every staged FDS game: the coverage sweep's audio hash changed for Metroid (Rev 3) and both Zelda no Densetsu dumps, with every frame hash unchanged. The import was not meant to change output, and v3.2.0 has no reason to, so this restores the pre-import body and its comments verbatim (c922ed40), plus a note at the site recording why the combined form is wrong. The import's `Fds::ppu_peek` (side-effect-free CHR-RAM read) is kept. New gate: `fds_output_floors_level_and_midpoint_separately` pins 147 / -147 / 6944 for the cases above. Mutation-checked: re-applying the import's combined division fails it with left 140, right 147. Verified: `RUSTYNES_COVERAGE_FILTER="fds/metroid,fds/zelda"` on `external_coverage` (test-roms,commercial-roms, release) passes and writes no .snap.new, so all three return to their blessed baselines. fmt and mappers clippy (-D warnings) clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/fds.rs | 49 ++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs index 5243668f..268aacda 100644 --- a/crates/rustynes-mappers/src/fds.rs +++ b/crates/rustynes-mappers/src/fds.rs @@ -1035,11 +1035,32 @@ impl FdsAudio { /// VRC6's loudness ballpark (`~±15k`) — comfortably under `i16::MAX`. #[cfg(feature = "mapper-audio")] pub(crate) fn output(&self) -> i16 { + // Volume gain clamps to 32 at the output multiply (PWM duty 32/32). let gain = u32::from(self.vol_gain.min(32)); let sample = u32::from(self.wave_out_latch & 0x3F); + // Master volume: 0=full(2/2), 1=2/3, 2=2/4, 3=2/5. Numerator 2, + // denominator (master_volume + 2). + let num = 2u32; let den = u32::from(self.master_volume) + 2; - let diff = sample as i32 - 32; - let centered = (diff * gain as i32 * 2) / den as i32; + // Raw product range: sample(0..=63) * gain(0..=32) = 0..=2016. + let raw = sample * gain; + // Apply master volume. + let scaled = raw * num / den; + // scaled max (master=full) = 2016 * 2 / 2 = 2016. Center bipolar around + // the mid-DAC (wave centered at 31.5 * gain * master). We output the raw + // level minus its midpoint, scaled to use a good chunk of i16 range. + // Midpoint of sample range is 31.5; approximate the DC bias as + // 32 * gain * num / den (sample == 32). + // + // The level and the midpoint are each floored SEPARATELY. A v3.2.0 + // import folded them into one `(sample - 32) * gain * 2 / den`, which + // truncates toward zero instead and moved every FDS game's audio + // (`fds_output_floors_level_and_midpoint_separately` pins it). + let mid = 32u32 * gain * num / den; + let centered = scaled as i32 - mid as i32; + // Scale factor 7: peak |centered| ~= 31 * 32 ~= 992 -> *7 ~= 6944 per + // full-gain step; the full-scale (gain 32, master full) reaches + // ~±6.9k, in the APU-square-x2.4 ballpark and well under i16::MAX. (centered * 7) as i16 } @@ -3305,6 +3326,30 @@ mod tests { assert_eq!(fds.audio.mod_counter, -1); } + /// The output floors the level and the midpoint separately. With master + /// volume 2/3 the two roundings differ: sample 63 at gain 1 is + /// `63*2/3 - 32*2/3 = 42 - 21 = 21` (x7 = 147), where a single combined + /// division gives `31*2/3 = 20` (x7 = 140). The combined form is what a + /// v3.2.0 import shipped; it moved all three staged FDS games' audio. + #[test] + #[cfg(feature = "mapper-audio")] + #[allow(clippy::field_reassign_with_default)] + fn fds_output_floors_level_and_midpoint_separately() { + let mut a = FdsAudio::default(); + a.vol_gain = 1; + a.master_volume = 1; + a.wave_out_latch = 63; + assert_eq!(a.output(), 147); + // Below the midpoint: 0 - 21 = -21 either way (x7 = -147). + a.wave_out_latch = 0; + assert_eq!(a.output(), -147); + // Full gain, full master: (63*32 - 32*32) = 992, x7 = 6944. + a.vol_gain = 32; + a.master_volume = 0; + a.wave_out_latch = 63; + assert_eq!(a.output(), 6944); + } + #[test] #[cfg(feature = "mapper-audio")] #[allow(clippy::field_reassign_with_default)] From 4410d32515b787cfd5eb0105ef06d2842aa6d32b Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 22:55:19 -0400 Subject: [PATCH 033/104] fix(mappers): revert the N163 output cache, which mixed inactive slots Round 2 item MAP-03 in the codex_sec import (bb66f0af) replaced the per-sample sum in `Namco163Audio::mix` with a cached running total: `recompute_cache` summed `channel_output(ch)` for all eight register slots on every data-port write and `load_state`, and `clock` patched the total by the delta of the channel it advanced. The original sums `channel_output(ch)` for `ch in 0..n` only, where `n` (1..=8) is the active channel count from `$7F` bits 6-4. With fewer than eight channels enabled the cache therefore added the inactive slots too. Those slots are ordinary internal RAM, and N163 games routinely keep wavetable data there, so they are rarely silent. The result is a wrong (and, with eight loud slots, i16-overflowing) mix for every game that uses fewer than eight channels. No staged commercial dump moved in the full sweep, which says only that none of the boot captures happens to reach a configuration that exposes it, not that the cache is safe: the defect is in the arithmetic. This restores the pre-import file (c922ed40) and keeps the import's `Namco163::ppu_peek` (side-effect-free CHR / nametable resolution); the two `channel_outputs` / `cached_sum` fields and `recompute_cache` are gone. No perf claim for the cache was measured on its own; if one is wanted, the correct form is to sum the active slots only, behind an A/B. New gate: `namco163_mix_ignores_inactive_channel_slots` sets one active channel and a loud inactive channel-7 slot and expects 105 * 261 = 27405. Mutation-checked: re-applying the import's cache gives -10726 (both slots summed, overflowed). All 27 namco163 unit tests pass; fmt and mappers clippy (-D warnings) clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/m019_namco163.rs | 62 +++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/crates/rustynes-mappers/src/m019_namco163.rs b/crates/rustynes-mappers/src/m019_namco163.rs index 5a4798f6..42f5b699 100644 --- a/crates/rustynes-mappers/src/m019_namco163.rs +++ b/crates/rustynes-mappers/src/m019_namco163.rs @@ -168,10 +168,6 @@ pub(crate) struct Namco163Audio { /// 15-cycle prescaler. When it reaches 15, we update the next /// channel and reset. prescaler: u8, - /// Cached individual channel outputs. - channel_outputs: [i16; 8], - /// Running sum of all channel outputs. - cached_sum: i32, } // When the `mapper-audio` feature is OFF, the audio struct still exists @@ -186,8 +182,6 @@ pub(crate) struct Namco163Audio { auto_inc: bool, tick_index: u8, prescaler: u8, - channel_outputs: [i16; 8], - cached_sum: i32, } impl Default for Namco163Audio { @@ -198,8 +192,6 @@ impl Default for Namco163Audio { auto_inc: false, tick_index: 0, prescaler: 0, - channel_outputs: [0; 8], - cached_sum: 0, } } } @@ -220,25 +212,11 @@ impl Namco163Audio { } } - #[cfg(feature = "mapper-audio")] - fn recompute_cache(&mut self) { - self.cached_sum = 0; - for ch in 0..8 { - let out = self.channel_output(ch); - self.channel_outputs[ch as usize] = out; - self.cached_sum += i32::from(out); - } - } - - #[cfg(not(feature = "mapper-audio"))] - fn recompute_cache(&mut self) {} - /// Write to the data port (`$4800-$4FFF`). Stores at the latched /// address; advances the latch when auto-increment is set. pub(crate) fn write_data_port(&mut self, value: u8) { let idx = (self.addr_latch & 0x7F) as usize; self.ram[idx] = value; - self.recompute_cache(); self.step_addr(); } @@ -367,11 +345,6 @@ impl Namco163Audio { phase %= modulus; } self.set_channel_phase(base, phase as u32); - - let old_out = self.channel_outputs[ch as usize]; - let new_out = self.channel_output(ch); - self.channel_outputs[ch as usize] = new_out; - self.cached_sum = self.cached_sum - i32::from(old_out) + i32::from(new_out); } /// Per-channel output sample, bipolar: `(nibble - 8) * volume`, @@ -412,9 +385,13 @@ impl Namco163Audio { if n == 0 { return 0; } + let mut sum: i32 = 0; + for ch in 0..n { + sum += i32::from(self.channel_output(ch)); + } // Per-channel range is -120..=+105; the channel-count-averaged sum has // the same envelope. Scale to the Mesen2 `db_n163` level. - ((self.cached_sum / i32::from(n)) * NAMCO163_MIX_SCALE) as i16 + ((sum / i32::from(n)) * NAMCO163_MIX_SCALE) as i16 } /// Feature-off shim: the wavetable generator does not advance with @@ -468,7 +445,6 @@ impl Namco163Audio { self.auto_inc = src[129] != 0; self.tick_index = src[130]; self.prescaler = src[131]; - self.recompute_cache(); Ok(()) } } @@ -1177,6 +1153,34 @@ mod tests { } } + /// The mix sums only the `n` ACTIVE channels. With one channel enabled, + /// channel 7's register slot (`$70-$77`) holds a loud, non-silent setup + /// -- on hardware those bytes are often wavetable data -- and must not + /// reach the output. Regression for a v3.2.0 import that cached a running + /// sum over all eight slots, so any game using fewer than eight channels + /// heard the inactive slots too. + #[test] + #[cfg(feature = "mapper-audio")] + fn namco163_mix_ignores_inactive_channel_slots() { + let mut m = namco163_for_audio(); + // Wavetable byte 0: low nibble 15 -> (15 - 8) = +7 per volume step. + n163_write_ram(&mut m, 0x00, false, 0x0F); + // Channel 8 ($78): length 4, wave address 0, phase 0; $7F = C=0 + // (one channel) and volume 15. + n163_write_ram(&mut m, 0x7C, false, 0xFC); + n163_write_ram(&mut m, 0x7E, false, 0x00); + n163_write_ram(&mut m, 0x7F, false, 0x0F); + // Channel 7 ($70), inactive with C=0: the same loud setup. + n163_write_ram(&mut m, 0x74, false, 0xFC); + n163_write_ram(&mut m, 0x76, false, 0x00); + n163_write_ram(&mut m, 0x77, false, 0x0F); + assert_eq!(m.audio.channel_count(), 1); + assert_eq!(m.audio.channel_output(0), 105); + assert_eq!(m.audio.channel_output(1), 105); + // Only channel 8 is mixed: 105 / 1 * NAMCO163_MIX_SCALE. + assert_eq!(i32::from(m.audio.mix()), 105 * NAMCO163_MIX_SCALE); + } + #[test] #[cfg(feature = "mapper-audio")] fn namco163_channel_count_selection() { From 5a38c02b93593975ff64c2ed5623c26bdefd3a07 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:03:41 -0400 Subject: [PATCH 034/104] fix(core): keep the PRG-window open-bus shortcut, gated on a board flag Round 2 item CORE-R2-02 in the codex_sec import (bb66f0af) changed the `$4020-$FFFF` read arm of `SystemBus::raw_cpu_read` from `if self.mapper.cpu_read_unmapped(addr)` to `if addr < 0x8000 && self.mapper.cpu_read_unmapped(addr)`. The point was real: PRG-ROM fetches are the hottest reads in the machine, and `cpu_read_unmapped` is a virtual call that answers "no" above `$8000` for almost every board. But two boards can leave that window undriven: Action 52 (mapper 228) with its absent third chip selected, and the Golden Game multicart (235) with an empty socket. The shortcut made both read whatever their `cpu_read` returns (`$00` for 228) instead of the floating bus value. The audit report called the change "100% bit-accurate"; it was not, and nothing in the suite noticed because no test read open bus above `$8000`. Fix, keeping the gain: a new provided trait method, `Mapper::may_unmap_prg_rom() -> bool` (default `false`), overridden to `true` by 228 and 235 and forwarded by the three wrapper boards (118, 119, 252) alongside `cpu_read_unmapped`. `SystemBus` caches it as `prg_may_unmap` next to `mapper_caps`, at the same two points (construction and the power-cycle rebuild), and the read arm becomes `(addr < 0x8000 || self.prg_may_unmap) && cpu_read_unmapped(addr)`. For every other board the PRG path costs one predictable branch on a cached bool instead of a virtual call. `MapperCaps` was the other candidate home and was rejected: its fields default to "dispatch everything" (`ALL`), the opposite polarity, and a new field means touching every board's literal. The contract (a board that floats `$8000-$FFFF` must declare it) is enforced three ways: - `bus::...::boards_that_unmap_prg_rom_read_open_bus_above_8000`: both boards through the real bus read the floating `$5A`, not ROM `$AA`. Red on the import's tree (228 read `$00`). - a `debug_assert!` on the read arm: debug builds check, on every PRG read, that an undeclared board never reports an unmapped address. - `mapper::caps_tests::only_declared_boards_unmap_prg_rom`: every mapper number, with CHR-ROM and again with CHR-RAM (235 refuses CHR-ROM), is driven through writes of `$00/$5A/$FF` to each `$xx00` page and asked about five PRG addresses after each; the declared set is pinned to [228, 235]. Mutation checks: 228 declaring `false` fails the bus test in release (reads `$00`) and trips the debug assertion; 235 declaring `false` fails the sweep ("mapper 235 floats $8000 after writing $00 to $8100"). Verified: core + mappers suites in a debug build (contract assertion live) 1,341 passed / 0 failed; fmt; clippy -D warnings on both crates. The perf effect is measured with the rest of the adopted perf work, not claimed here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/bus.rs | 68 ++++++++++++++++++- crates/rustynes-mappers/src/m118_txsrom.rs | 4 ++ crates/rustynes-mappers/src/m119_tqrom.rs | 4 ++ crates/rustynes-mappers/src/m228_action52.rs | 5 ++ .../rustynes-mappers/src/m235_golden_game.rs | 5 ++ .../rustynes-mappers/src/m252_waixing_vrc4.rs | 4 ++ crates/rustynes-mappers/src/mapper.rs | 67 ++++++++++++++++++ 7 files changed, 156 insertions(+), 1 deletion(-) diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index b240f153..8993e520 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -345,6 +345,11 @@ pub struct SystemBus { /// `notify_frame_event` / `irq_pending`) on boards that don't use /// them. Constant per mapper type; NOT part of the save-state. mapper_caps: rustynes_mappers::MapperCaps, + /// v3.2.0 — [`Mapper::may_unmap_prg_rom`], cached beside + /// [`Self::mapper_caps`] with the same refresh points. When `false` the + /// `$8000-$FFFF` read path skips the per-read `cpu_read_unmapped` + /// dispatch. Constant per mapper type; NOT part of the save-state. + prg_may_unmap: bool, /// The original iNES/NES-2.0 ROM bytes, kept so [`Self::power_cycle`] can /// rebuild the mapper to a true power-on state (fresh bank registers, /// cleared CHR-RAM + volatile PRG-RAM). `None` on the FDS path (which has @@ -949,6 +954,7 @@ impl SystemBus { // v2.8.0 Phase 4 — cache the capability flags once (constant per // mapper type); the per-cycle hot loop reads the copy. let mapper_caps = mapper.caps(); + let prg_may_unmap = mapper.may_unmap_prg_rom(); let mut bus = Self { ram: fresh_ram(), ppu: Ppu::new(region), @@ -956,6 +962,7 @@ impl SystemBus { cart, mapper, mapper_caps, + prg_may_unmap, // Set by `with_sample_rate` (iNES path); stays `None` for FDS. rom_bytes: None, controllers: [Controller::new(); 2], @@ -1295,6 +1302,7 @@ impl SystemBus { // fresh mapper instance (same type, same flags, but keep // the invariant mechanical). self.mapper_caps = self.mapper.caps(); + self.prg_may_unmap = self.mapper.may_unmap_prg_rom(); // v3.1.0: a fresh board starts on its header's MMC3 revision; // the override is configuration and carries over. if self.mmc3_revision_override.is_some() { @@ -3802,7 +3810,17 @@ impl SystemBus { self.vs_overlay_4017(base) } 0x4020..=0xFFFF => { - if addr < 0x8000 && self.mapper.cpu_read_unmapped(addr) { + // `$8000-$FFFF` asks only a board that can leave it undriven + // (`prg_may_unmap`, v3.2.0): PRG-ROM fetches are the hottest + // reads, and almost every board always drives them. A v3.2.0 + // import skipped the question above `$8000` for EVERY board, + // which read ROM where 228 / 235 float the bus + // (`boards_that_unmap_prg_rom_read_open_bus_above_8000`). + debug_assert!( + addr < 0x8000 || self.prg_may_unmap || !self.mapper.cpu_read_unmapped(addr), + "mapper unmaps ${addr:04X} but does not declare may_unmap_prg_rom" + ); + if (addr < 0x8000 || self.prg_may_unmap) && self.mapper.cpu_read_unmapped(addr) { // Unmapped read: nothing drives the bus, so the CPU sees // the floating-latch value (nesdev "Open bus behavior": // "all it sees on its data inputs is whatever was left to @@ -5232,6 +5250,54 @@ mod four_score_tests { ); } + /// iNES image for mapper `mapper` with `prg_16k` x 16 KiB of PRG filled + /// with `$AA` and CHR-RAM. + fn prg_filled_rom(mapper: u8, prg_16k: u8) -> Vec { + let mut rom = Vec::new(); + rom.extend_from_slice(b"NES\x1A"); + rom.push(prg_16k); + rom.push(0); // CHR-RAM + rom.push((mapper & 0x0F) << 4); + rom.push(mapper & 0xF0); + rom.extend_from_slice(&[0u8; 8]); + rom.resize(16 + usize::from(prg_16k) * 0x4000, 0xAA); + rom + } + + /// Two boards can leave `$8000-$FFFF` undriven: *Action 52* (mapper 228) + /// with its absent third chip selected, and the Golden Game multicart + /// (mapper 235) with an empty socket selected. Both must read open bus + /// there, not ROM. Regression for a v3.2.0 import that consulted + /// `cpu_read_unmapped` only below `$8000` (a hot-path shortcut), which made + /// both read the ROM byte (`$AA` here) instead of the floating `$5A`. + #[test] + fn boards_that_unmap_prg_rom_read_open_bus_above_8000() { + // 228: 1.5 MiB = chips 0, 1, 3. `$9000` selects chip 2 (addr bits + // 12-11 = 0b10), the one the image does not carry. + let mut bus = SystemBus::new(&prg_filled_rom(228, 96)).expect("228 parses"); + assert_eq!(bus.raw_cpu_read(0x8000), 0xAA, "fixture: chip 0 is ROM"); + bus.mapper.cpu_write(0x9000, 0x00); + bus.ram[0] = 0x5A; + assert_eq!(bus.raw_cpu_read(0x0000), 0x5A); + assert_eq!( + bus.raw_cpu_read(0x8000), + 0x5A, + "228 absent chip floats the bus" + ); + + // 235: a 1 MiB image fills socket 0 only; `$8100` selects socket 1. + let mut bus = SystemBus::new(&prg_filled_rom(235, 64)).expect("235 parses"); + assert_eq!(bus.raw_cpu_read(0x8000), 0xAA, "fixture: socket 0 is ROM"); + bus.mapper.cpu_write(0x8100, 0x00); + bus.ram[0] = 0x5A; + assert_eq!(bus.raw_cpu_read(0x0000), 0x5A); + assert_eq!( + bus.raw_cpu_read(0xC000), + 0x5A, + "235 empty socket floats the bus" + ); + } + #[test] fn internal_data_bus_round_trips_through_save_state() { // v2.8.0 (libretro audit §2.4). The 2A03's internal data bus is a diff --git a/crates/rustynes-mappers/src/m118_txsrom.rs b/crates/rustynes-mappers/src/m118_txsrom.rs index 247014dd..712a2921 100644 --- a/crates/rustynes-mappers/src/m118_txsrom.rs +++ b/crates/rustynes-mappers/src/m118_txsrom.rs @@ -145,6 +145,10 @@ impl Mapper for TxSrom { self.inner.cpu_read_unmapped(addr) } + fn may_unmap_prg_rom(&self) -> bool { + self.inner.may_unmap_prg_rom() + } + fn ppu_read(&mut self, addr: u16) -> u8 { self.inner.ppu_read(addr) } diff --git a/crates/rustynes-mappers/src/m119_tqrom.rs b/crates/rustynes-mappers/src/m119_tqrom.rs index ec5995cf..448f50ae 100644 --- a/crates/rustynes-mappers/src/m119_tqrom.rs +++ b/crates/rustynes-mappers/src/m119_tqrom.rs @@ -128,6 +128,10 @@ impl Mapper for Tqrom { self.inner.cpu_read_unmapped(addr) } + fn may_unmap_prg_rom(&self) -> bool { + self.inner.may_unmap_prg_rom() + } + fn ppu_read(&mut self, addr: u16) -> u8 { let a = addr & 0x3FFF; if a < 0x2000 { diff --git a/crates/rustynes-mappers/src/m228_action52.rs b/crates/rustynes-mappers/src/m228_action52.rs index d7bba962..e8390c7e 100644 --- a/crates/rustynes-mappers/src/m228_action52.rs +++ b/crates/rustynes-mappers/src/m228_action52.rs @@ -147,6 +147,11 @@ impl Mapper for Action52M228 { self.data = 0; } + fn may_unmap_prg_rom(&self) -> bool { + // The three-chip image's chip 2 is absent: `$8000-$FFFF` floats. + true + } + fn cpu_read_unmapped(&self, addr: u16) -> bool { match addr { 0x4020..=0x7FFF => true, diff --git a/crates/rustynes-mappers/src/m235_golden_game.rs b/crates/rustynes-mappers/src/m235_golden_game.rs index e5eb4754..4c75147c 100644 --- a/crates/rustynes-mappers/src/m235_golden_game.rs +++ b/crates/rustynes-mappers/src/m235_golden_game.rs @@ -134,6 +134,11 @@ impl Mapper for GoldenGame235 { self.reg = 0; } + fn may_unmap_prg_rom(&self) -> bool { + // An image smaller than 4 MiB leaves sockets empty: they float. + true + } + fn cpu_read_unmapped(&self, addr: u16) -> bool { match addr { 0x4020..=0x7FFF => true, diff --git a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs index 34f12c2e..1432e46d 100644 --- a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs +++ b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs @@ -91,6 +91,10 @@ impl Mapper for Waixing252 { self.inner.cpu_read_unmapped(addr) } + fn may_unmap_prg_rom(&self) -> bool { + self.inner.may_unmap_prg_rom() + } + fn cpu_read(&mut self, addr: u16) -> u8 { self.inner.cpu_read(addr) } diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 502d1b06..5c38775b 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -252,6 +252,25 @@ pub trait Mapper: Send { } } + /// Whether [`Self::cpu_read_unmapped`] can ever return `true` for an + /// address in `$8000-$FFFF`, on this board, in any state. + /// + /// Almost every cartridge drives the bus across the whole PRG-ROM window, + /// so the bus skips the per-read [`Self::cpu_read_unmapped`] call there + /// for every board that leaves this `false` -- PRG-ROM fetches are the + /// hottest reads in the machine. A board that can leave part of + /// `$8000-$FFFF` undriven (an absent chip, an empty socket) returns + /// `true`, and the bus then asks on every read as before. Today that is + /// *Action 52* (228) and the Golden Game multicart (235). A board that + /// wraps another must forward this along with `cpu_read_unmapped`. + /// + /// Read once when the bus takes the mapper, so it must not depend on + /// runtime state. Debug builds check the contract on every PRG read. The + /// default is `false`. Added in v3.2.0. + fn may_unmap_prg_rom(&self) -> bool { + false + } + /// A CPU read the mapper declined ([`Self::cpu_read_unmapped`]) has just /// completed, and `value` is what floated on the bus. /// @@ -920,6 +939,54 @@ mod caps_tests { ); } + /// v3.2.0: the bus skips `cpu_read_unmapped` in `$8000-$FFFF` for every + /// board that does not declare `may_unmap_prg_rom`, so a board that can + /// float that window and does not declare it would silently read ROM. + /// Every parsable mapper number is driven through a spread of register + /// writes, and after each one the window is asked; an undeclared `true` + /// fails. The declared set is pinned too, so adding a board to it is a + /// visible change. Each number is tried with CHR-ROM and again with + /// CHR-RAM, since some boards (235 among them) refuse one or the other. + #[test] + fn only_declared_boards_unmap_prg_rom() { + fn chr_ram_rom(id: u8) -> Vec { + let mut rom = synth_rom(id); + rom[5] = 0; // no CHR-ROM: CHR-RAM + rom.truncate(16 + 32 * 1024); + rom + } + let mut declared = Vec::new(); + let mut checked = 0usize; + for id in 0u8..=255 { + for rom in [synth_rom(id), chr_ram_rom(id)] { + let Ok((_cart, mut mapper)) = parse(&rom) else { + continue; + }; + if mapper.may_unmap_prg_rom() { + if !declared.contains(&id) { + declared.push(id); + } + continue; + } + for page in (0x8000u16..=0xFF00).step_by(0x100) { + for value in [0x00u8, 0x5A, 0xFF] { + mapper.cpu_write(page, value); + for addr in [0x8000u16, 0xA000, 0xC000, 0xE000, 0xFFFC] { + assert!( + !mapper.cpu_read_unmapped(addr), + "mapper {id} floats ${addr:04X} after writing ${value:02X} \ + to ${page:04X} but does not declare may_unmap_prg_rom" + ); + } + } + } + checked += 1; + } + } + assert!(checked > 150, "only {checked} boards were checked"); + assert_eq!(declared, vec![228, 235], "the declared set moved"); + } + /// v2.8.0 Phase 4 — the capability-flag contract for the key families. /// A flag may be `false` ONLY when the mapper does not override the /// corresponding default no-op; these spot checks pin the mechanical From 37102eb5f4852c566fa045dc5028948d2783435e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:05:12 -0400 Subject: [PATCH 035/104] fix(mappers): recompute GxROM's cached bank offsets on load_state The codex_sec import (bb66f0af, MAP-01) cached GxROM's PRG and CHR bank byte offsets (`prg_bank_offset`, `chr_bank_offset`) so `read_prg` and `ppu_read` skip a division and a modulo per access, and refreshed them from `cpu_write` through `update_bank_offsets`. `load_state` restored `prg_bank` and `chr_bank` but not the offsets, which are derived state and not serialised. A restore into an instance whose cache disagreed -- any fresh instance (a second run-ahead or netplay core, a power-cycled machine), or a rewind across a bank switch -- therefore kept reading the OLD banks until the game next wrote the register. Dragon Power and the other GxROM titles would have shown wrong CHR and could have run code from the wrong PRG bank straight after a state load. Fix: `load_state` calls `update_bank_offsets()` after restoring the registers. Also removed two dead `_prg_bank_count` / `_chr_bank_count` computations the import left in `new()`; the initial offsets are now the literal 0 they always were, with a comment naming the invariant. Red first: `gxrom_load_state_restores_the_bank_offsets` (save with PRG 2 / CHR 1, restore into a fresh board) read PRG bank 0 before the fix (left 0, right 2) and passes after; it also checks `ppu_read` and the import's `ppu_peek`. The other two gxrom tests still pass; mappers clippy -D warnings and fmt clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/m066_gxrom.rs | 34 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/rustynes-mappers/src/m066_gxrom.rs b/crates/rustynes-mappers/src/m066_gxrom.rs index 021754f9..2c78a343 100644 --- a/crates/rustynes-mappers/src/m066_gxrom.rs +++ b/crates/rustynes-mappers/src/m066_gxrom.rs @@ -52,18 +52,16 @@ impl GxRom { chr_rom.len() ))); } - let _prg_bank_count = (prg_rom.len() / PRG_BANK_32K).max(1); - let prg_bank_offset = 0; - let _chr_bank_count = (chr_rom.len() / CHR_BANK_8K).max(1); - let chr_bank_offset = 0; Ok(Self { prg_rom, chr_rom, vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), prg_bank: 0, chr_bank: 0, - prg_bank_offset, - chr_bank_offset, + // Bank 0 at power-on; `update_bank_offsets` keeps these in step + // with `prg_bank` / `chr_bank` on every write and restore. + prg_bank_offset: 0, + chr_bank_offset: 0, mirroring, }) } @@ -174,6 +172,9 @@ impl Mapper for GxRom { self.prg_bank = data[1]; self.chr_bank = data[2]; self.vram.copy_from_slice(&data[3..3 + self.vram.len()]); + // The offsets are derived state, not saved: recompute them from the + // restored bank registers. + self.update_bank_offsets(); Ok(()) } } @@ -212,6 +213,27 @@ mod tests { assert_eq!(m.ppu_read(0x0000), 3); } + /// A restored state reads through the restored banks. Regression for a + /// v3.2.0 import that cached the PRG / CHR bank offsets but did not + /// recompute them in `load_state`, so a restore into a fresh instance + /// kept reading bank 0 until the next register write. + #[test] + fn gxrom_load_state_restores_the_bank_offsets() { + let mut m = GxRom::new(synth_prg(4), synth_chr(4), Mirroring::Vertical).unwrap(); + m.cpu_write(0x8001, 0x21); // PRG bank 2, CHR bank 1 + let state = m.save_state(); + let mut fresh = GxRom::new(synth_prg(4), synth_chr(4), Mirroring::Vertical).unwrap(); + assert_eq!( + fresh.cpu_read(0x8000), + 0, + "fixture: a fresh board is on bank 0" + ); + fresh.load_state(&state).unwrap(); + assert_eq!(fresh.cpu_read(0x8000), 2, "PRG bank 2 after restore"); + assert_eq!(fresh.ppu_read(0x0000), 1, "CHR bank 1 after restore"); + assert_eq!(fresh.ppu_peek(0x0000), 1, "and through the peek path"); + } + #[test] fn gxrom_bus_conflict_at_offset_zero() { // At offset 0 of bank 0, byte = 0x00. ANDing kills the write. From c7126096998aa5831421f31df2e49cf3962b67e8 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:13:46 -0400 Subject: [PATCH 036/104] fix(mappers): mask MMC3 bank numbers in the cached offset tables The codex_sec import (bb66f0af, MAP-01) replaced MMC3's per-access bank resolution with two cached tables, `prg_offsets[4]` and `chr_offsets[8]`, rebuilt when R0-R7 or the mode bits change. That is a real saving on the hottest mapper in the library -- `prg_offset` and `chr_offset` become a table index -- and it stays. But the rebuild reduced bank numbers MODULO the bank count (`r6 % total_banks`, `bank_1k % total_banks_1k`) where the code it replaced MASKED them (`r6 & last`, `bank_1k & mask`). Masking is what a board does: an unconnected high address line simply drops the bit. For power-of-two images the two agree, which is why the full suite did not move; for a non-power-of-two image they do not. Three 8 KiB PRG banks with R6 = 3: masked, bank 2; modulo, bank 0. Fix: both rebuilds mask again, exactly as before the import. The `% len` the import removed from the PRG and CHR read sites stays removed: with masking every offset is in range, because the constructor requires PRG to be a non-empty multiple of 8 KiB and CHR a multiple of 1 KiB (CHR-RAM is 8 KiB). The `.max(1)` guards are gone with the modulo they protected. Both functions now carry the reasoning at the site. Second defect, same tables: the import rebuilt them at the very end of `load_state`, but two error paths return after `regs`, `prg_mode` and `chr_mode` have already been overwritten, so a refused state left the tables describing registers the board no longer held. The tables depend on exactly those three fields (`chr_bank_1k_for_slot` reads only `regs` and `chr_mode`), so the rebuild now runs immediately after they are restored, before any later check. Red first: `bank_numbers_are_masked_not_reduced_modulo` (3 PRG + 3 CHR banks, R6 = R2 = 3, expects bank 2 through `cpu_read`, `ppu_read`, `ppu_peek` and after a restore into a fresh board) read bank 0 on the import's code (left 0, right 2) and passes now. Verified: mappers lib 1,069 passed; commercial real-games oracle 60/60; harness `mmc3` 21 passed (2 ignored, as before) and `mmc3_clone_a12` 5/5; coverage sweep filtered to mappers 004/118/119: 44 of 48 match, the 4 others being the TxSROM dumps staged on 2026-10-08 with no baseline yet (none moved). fmt and mappers clippy -D warnings clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/m004_mmc3.rs | 56 +++++++++++++++++++++--- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/crates/rustynes-mappers/src/m004_mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs index c79dae2d..8129dff9 100644 --- a/crates/rustynes-mappers/src/m004_mmc3.rs +++ b/crates/rustynes-mappers/src/m004_mmc3.rs @@ -443,12 +443,21 @@ impl Mmc3 { self.regs[r & 7] } + /// Rebuild the four 8 KiB PRG slot offsets from R6 / R7 and the PRG + /// mode. Called on every write that changes them, at construction and + /// from `load_state`, so `prg_offset` is a table lookup. + /// + /// R6 / R7 are MASKED to the image (`& last`, the address lines the + /// board wires), never reduced modulo the bank count: the two differ for + /// a non-power-of-two image (`bank_numbers_are_masked_not_reduced_modulo`). + /// Every offset is in range because the constructor requires a non-empty + /// multiple of 8 KiB, so `prg_rom` needs no `% len` at the read. fn update_prg_offsets(&mut self) { - let total_banks = (self.prg_rom.len() / PRG_BANK_8K).max(1); + let total_banks = self.prg_rom.len() / PRG_BANK_8K; let last = total_banks.saturating_sub(1); let second_last = total_banks.saturating_sub(2); - let r6 = (self.regs[6] as usize) % total_banks; - let r7 = (self.regs[7] as usize) % total_banks; + let r6 = (self.regs[6] as usize) & last; + let r7 = (self.regs[7] as usize) & last; self.prg_offsets[0] = (if !self.prg_mode { r6 } else { second_last }) * PRG_BANK_8K; self.prg_offsets[1] = r7 * PRG_BANK_8K; self.prg_offsets[2] = (if !self.prg_mode { second_last } else { r6 }) * PRG_BANK_8K; @@ -509,11 +518,15 @@ impl Mmc3 { } } + /// Rebuild the eight 1 KiB CHR slot offsets from R0-R5 and the CHR mode + /// (layout in [`Self::chr_bank_1k_for_slot`]). Masked like the PRG table: + /// `& (banks - 1)`, not `%`. CHR is a non-zero multiple of 1 KiB (CHR-RAM + /// is 8 KiB), so every offset is in range. fn update_chr_offsets(&mut self) { - let total_banks_1k = (self.chr.len() / CHR_BANK_1K).max(1); + let mask = (self.chr.len() / CHR_BANK_1K).saturating_sub(1); for slot in 0..8 { let bank_1k = self.chr_bank_1k_for_slot(slot); - self.chr_offsets[slot] = (bank_1k % total_banks_1k) * CHR_BANK_1K; + self.chr_offsets[slot] = (bank_1k & mask) * CHR_BANK_1K; } } @@ -1062,6 +1075,11 @@ impl Mapper for Mmc3 { self.bank_select = data[9]; self.prg_mode = data[10] != 0; self.chr_mode = data[11] != 0; + // The offset tables depend on exactly these fields. Rebuild them now, + // before any later check can return an error, so a refused state + // never leaves the tables describing different registers. + self.update_prg_offsets(); + self.update_chr_offsets(); self.mirroring = match data[12] { 0 => Mirroring::Horizontal, 1 => Mirroring::Vertical, @@ -1124,8 +1142,6 @@ impl Mapper for Mmc3 { self.mmc6_ram_enabled = data[cur] != 0; self.mmc6_protect = data[cur + 1] & 0xF0; self.mcacc_prescaler = data[cur + 2] & 0x07; - self.update_prg_offsets(); - self.update_chr_offsets(); Ok(()) } } @@ -1162,6 +1178,32 @@ mod tests { .unwrap() } + /// Bank numbers are MASKED to the image (`& (banks - 1)`, the address + /// lines a board actually wires), not reduced modulo the bank count. The + /// two agree for power-of-two images; for three 8 KiB PRG banks and three + /// 1 KiB CHR banks, register value 3 masks to bank 2 but is bank 0 modulo + /// 3. Regression for a v3.2.0 import whose cached offset tables used `%`. + /// The tables must also follow a restored state, including when + /// `load_state` fails after the registers were already written. + #[test] + fn bank_numbers_are_masked_not_reduced_modulo() { + let mut m = fresh(3, 3); + m.cpu_write(0x8000, 6); // select R6 (PRG at $8000 in mode 0) + m.cpu_write(0x8001, 3); + assert_eq!(m.cpu_read(0x8000), 2, "PRG: 3 & 2 = bank 2"); + m.cpu_write(0x8000, 2); // select R2 (1 KiB CHR at $1000 in mode 0) + m.cpu_write(0x8001, 3); + assert_eq!(m.ppu_read(0x1000), 2, "CHR: 3 & 2 = bank 2"); + assert_eq!(m.ppu_peek(0x1000), 2, "and through the peek path"); + + // A restore into a fresh board rebuilds the tables. + let state = m.save_state(); + let mut other = fresh(3, 3); + other.load_state(&state).unwrap(); + assert_eq!(other.cpu_read(0x8000), 2); + assert_eq!(other.ppu_read(0x1000), 2); + } + #[test] fn last_8k_bank_fixed_at_e000() { let mut m = fresh(8, 8); From 298a2cb8a0c2508145d57d8faeec6ccb2aacfdd6 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:18:13 -0400 Subject: [PATCH 037/104] fix(mappers): make ppu_peek required and exact; fix mapper 209's peek The codex_sec import (bb66f0af) added `Mapper::ppu_peek` -- a side-effect-free PPU read -- with a default body returning `0`, gave ~150 boards their own body, and switched `SystemBus::debug_peek_ppu` (the debugger's CHR / PPU memory view) from "`ppu_read`, wrapped in a save_state / load_state on boards whose CHR reads are impure" to a plain `ppu_peek`. That removes a state round-trip per debugger byte on the impure boards, and it stays. Two things about it did not: 1. The default `0`. A board that forgot to implement `ppu_peek` would show an all-zero pattern table in the debugger and nothing else would notice. The method is now REQUIRED (no default); every one of the 151 implementations already provides it, so this changes no board -- it makes the omission a compile error from now on. Verified with `cargo check --workspace --all-targets` (test-roms, commercial-roms); the excluded `rustynes-cosim` and `fuzz` crates implement no mapper. 2. Mapper 209 (J.Y. Company, MMC4-style CHR latch). Its `ppu_read` updates the latch BEFORE resolving the bank, so a fetch of a trigger address ($xFD8 / $xFE8) reads through the NEW latch. The imported `ppu_peek` resolved through the OLD one: in CHR mode 1 a peek of $0FE8 returned 1 KiB bank 7 where the read returns bank 11. Fix: the latch computation moves into a pure `chr_latch_after_fetch(&self, addr)`; `update_chr_latch_209` stores its result, and `ppu_peek` resolves through it via a new `chr_offset_with_latch` without storing it. The existing "Mesen:" comment above the latch expression moves with the expression, unchanged -- it is one of the provenance questions already raised with the maintainer, and rewording it is not this change's call. Contract and gates: - `ppu_peek`'s rustdoc now states the contract: exactly the byte `ppu_read` would return, with no state change. - `mapper::caps_tests::ppu_peek_matches_a_side_effect_free_ppu_read`: every parsable mapper number, with patterned CHR-ROM and with CHR-RAM, after four register writes, compares `ppu_peek` against `ppu_read` inside a save / restore (the debugger's pre-v3.2.0 behaviour, used as the oracle) at a stride across $0000-$1FFF plus the four MMC2/MMC4 latch triggers. - `m035_jy_asic::...::mapper209_ppu_peek_sees_the_latch_the_read_would_set`: CHR mode 1 with distinct window registers; red on the import's peek (left 7, right 11), green now. The generic sweep did NOT catch this board on its own -- its writes never select CHR mode 1 -- which is why the board test exists. Verified: mappers lib 1,071 passed; mappers clippy -D warnings and fmt clean; workspace check as above. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/m035_jy_asic.rs | 60 ++++++++++++++-- crates/rustynes-mappers/src/mapper.rs | 78 +++++++++++++++++++-- 2 files changed, 129 insertions(+), 9 deletions(-) diff --git a/crates/rustynes-mappers/src/m035_jy_asic.rs b/crates/rustynes-mappers/src/m035_jy_asic.rs index e5e75679..77fc9fc0 100644 --- a/crates/rustynes-mappers/src/m035_jy_asic.rs +++ b/crates/rustynes-mappers/src/m035_jy_asic.rs @@ -420,6 +420,13 @@ impl JyAsic { /// Resolve a physical CHR offset for a PPU pattern-table address. fn chr_offset(&self, addr: u16) -> usize { + self.chr_offset_with_latch(addr, self.chr_latch) + } + + /// [`Self::chr_offset`] through an explicit MMC4-style latch, so + /// `ppu_peek` can resolve through the latch a fetch WOULD set without + /// setting it. + fn chr_offset_with_latch(&self, addr: u16, chr_latch: [u8; 2]) -> usize { let addr = (addr & 0x1FFF) as usize; let total_1k = (self.chr.len() / CHR_BANK_1K).max(1); let slot_1k = addr / CHR_BANK_1K; // 0..=7 @@ -429,9 +436,9 @@ impl JyAsic { // 4 KiB: two windows, MMC4 latch picks the register for each. 1 => { let reg = if addr < 0x1000 { - self.chr_latch[0] as usize + chr_latch[0] as usize } else { - self.chr_latch[1] as usize + chr_latch[1] as usize }; (self.chr_reg(reg) as usize) * 4 + (slot_1k & 0x03) } @@ -489,17 +496,26 @@ impl JyAsic { /// Update the MMC4-like CHR latch on mapper 209 when a pattern fetch hits a /// sentinel address ($x FD8-$x FDF / $x FE8-$x FEF). fn update_chr_latch_209(&mut self, addr: u16) { + self.chr_latch = self.chr_latch_after_fetch(addr); + } + + /// The CHR latch as it stands after a pattern fetch of `addr`, without + /// storing it: [`Self::update_chr_latch_209`] stores it, `ppu_peek` + /// resolves through it (`mapper209_ppu_peek_sees_the_latch_the_read_would_set`). + fn chr_latch_after_fetch(&self, addr: u16) -> [u8; 2] { + let mut chr_latch = self.chr_latch; if !matches!(self.board, JyBoard::M209 | JyBoard::M35) { - return; + return chr_latch; } match addr & 0x2FF8 { 0x0FD8 | 0x0FE8 => { // Mesen: chrLatch[addr>>12] = (addr>>4) & ((addr>>10 & 4) | 2) let idx = (addr >> 12) as usize & 0x01; - self.chr_latch[idx] = ((addr >> 4) as u8) & (((addr >> 10) as u8 & 0x04) | 0x02); + chr_latch[idx] = ((addr >> 4) as u8) & (((addr >> 10) as u8 & 0x04) | 0x02); } _ => {} } + chr_latch } /// Whether the given nametable index ($2000-$2FFF / index 0..=3) reads from @@ -734,7 +750,9 @@ impl Mapper for JyAsic { // `nametable_address` instead (the PPU owns CIRAM), so this only // services CHR. let addr = addr & 0x1FFF; - let off = self.chr_offset(addr); + // `ppu_read` moves the latch BEFORE it resolves the bank, so a fetch + // of a latch trigger reads through the new latch; peek the same way. + let off = self.chr_offset_with_latch(addr, self.chr_latch_after_fetch(addr)); self.chr[off % self.chr.len()] } @@ -1326,6 +1344,38 @@ mod tests { assert_eq!(m.chr_latch[0], 2); } + /// `ppu_peek` returns the byte `ppu_read` would, and `ppu_read` on 209 + /// updates the MMC4-style latch BEFORE it resolves the bank, so a fetch + /// of a latch trigger reads through the NEW latch. Regression for a + /// v3.2.0 import whose `ppu_peek` resolved through the old latch, so the + /// debugger's CHR view showed a byte the PPU never fetches. + #[test] + fn mapper209_ppu_peek_sees_the_latch_the_read_would_set() { + // Every byte of 1 KiB bank `b` is `b`, so a byte names its bank. + let mut chr = vec![0u8; 256 * CHR_BANK_1K]; + for (i, v) in chr.iter_mut().enumerate() { + *v = (i / CHR_BANK_1K) as u8; + } + let mut m = JyAsic::new( + synth_prg(64), + chr.into_boxed_slice(), + Mirroring::Vertical, + JyBoard::M209, + ) + .unwrap(); + m.cpu_write(0xD000, 0x08); // CHR mode 1: two 4 KiB windows + m.cpu_write(0x9000, 1); // reg 0 -> 4 KiB bank 1 (1 KiB banks 4-7) + m.cpu_write(0x9002, 2); // reg 2 -> 4 KiB bank 2 (1 KiB banks 8-11) + for addr in [0x0FD8u16, 0x0FE8, 0x1FD8, 0x1FE8, 0x0FE0, 0x0000] { + let saved = m.save_state(); + let read = m.ppu_read(addr); + m.load_state(&saved).unwrap(); + assert_eq!(m.ppu_peek(addr), read, "${addr:04X}"); + } + // And the peek really did see a flip: $0FE8 selects reg 2. + assert_eq!(m.ppu_peek(0x0FE8), 11, "bank 2 * 4 + slot 3"); + } + #[test] fn mapper90_no_mmc4_latch() { let mut m = fresh(JyBoard::M90); diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 5c38775b..c2609d88 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -326,10 +326,21 @@ pub trait Mapper: Send { /// + nametable mirror window). Used as the BG-side / generic fetch path. fn ppu_read(&mut self, addr: u16) -> u8; - /// Read a PPU byte without side-effects for inspection/debugging. - fn ppu_peek(&self, _addr: u16) -> u8 { - 0 - } + /// Read a PPU byte (`$0000-$3FFF`) without side effects, for the + /// debugger and other inspection. + /// + /// It must return exactly the byte [`Self::ppu_read`] would return at + /// `addr` in the current state, and change nothing: a CHR latch the read + /// would flip is resolved as flipped but not stored, an IRQ counter a + /// read would clock is not clocked. `SystemBus::debug_peek_ppu` relies on + /// it for every board (until v3.2.0 it called `ppu_read` inside a save / + /// restore), and `ppu_peek_matches_a_side_effect_free_ppu_read` checks it + /// for every mapper. + /// + /// Required, with no default: a default `0` (as first written in v3.2.0) + /// would make a board that forgot it show a blank pattern table in the + /// debugger with no other symptom. + fn ppu_peek(&self, addr: u16) -> u8; /// Read a byte from the PPU pattern-table window (`$0000-$1FFF`) on /// behalf of a *sprite* tile fetch. MMC5 in 8x16 sprite mode uses a @@ -987,6 +998,65 @@ mod caps_tests { assert_eq!(declared, vec![228, 235], "the declared set moved"); } + /// v3.2.0: `ppu_peek` returns exactly the byte `ppu_read` would, without + /// its side effects. The debugger's PPU view (`SystemBus::debug_peek_ppu`) + /// reads CHR through `ppu_peek` on every board; before v3.2.0 it called + /// `ppu_read` and, on a board with impure CHR reads, restored the state + /// afterwards. That old behaviour is the oracle here: for every parsable + /// mapper (CHR-ROM and CHR-RAM), after a spread of register writes, each + /// sampled CHR address is read through `ppu_read` inside a save / restore + /// and compared with `ppu_peek`. + #[test] + fn ppu_peek_matches_a_side_effect_free_ppu_read() { + fn chr_ram_rom(id: u8) -> Vec { + let mut rom = synth_rom(id); + rom[5] = 0; + rom.truncate(16 + 32 * 1024); + rom + } + fn rom_with_patterned_chr(id: u8) -> Vec { + let mut rom = synth_rom(id); + for (i, b) in rom[16 + 32 * 1024..].iter_mut().enumerate() { + let [lo, hi, ..] = i.to_le_bytes(); + *b = lo.wrapping_mul(31) ^ hi; + } + rom + } + let mut checked = 0usize; + for id in 0u8..=255 { + for rom in [rom_with_patterned_chr(id), chr_ram_rom(id)] { + let Ok((_cart, mut mapper)) = parse(&rom) else { + continue; + }; + for (reg, value) in [ + (0x8000u16, 0x00u8), + (0x8001, 0x03), + (0xA000, 0x05), + (0xC000, 0x1F), + ] { + mapper.cpu_write(reg, value); + // A stride across the window, plus the MMC2 / MMC4-style + // latch triggers, where a read changes the bank it reads. + let triggers = [0x0FD8u16, 0x0FE8, 0x1FD8, 0x1FE8]; + for addr in (0u16..0x2000).step_by(0x0FD).chain(triggers) { + let saved = mapper.save_state(); + let read = mapper.ppu_read(addr); + mapper + .load_state(&saved) + .expect("a board reloads its own state"); + assert_eq!( + mapper.ppu_peek(addr), + read, + "mapper {id}: ppu_peek(${addr:04X}) differs from ppu_read" + ); + } + } + checked += 1; + } + } + assert!(checked > 150, "only {checked} boards were checked"); + } + /// v2.8.0 Phase 4 — the capability-flag contract for the key families. /// A flag may be `false` ONLY when the mapper does not override the /// corresponding default no-op; these spot checks pin the mechanical From 85881197923a48615e2353739e086c7111f43234 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:21:31 -0400 Subject: [PATCH 038/104] fix(gamedb): document and pin the import's header-correction changes The codex_sec import (bb66f0af) changed `rustynes-gamedb` in four places and gave none of them a test or an explanation, and dropped the Seicross comment that explains the iNES-1.0 promotion. All four are kept after review; this commit restores the comment, records why each change is right, and pins the two behaviour changes. 1. `same_board` no longer compares `submapper`; `ines1_as_nes2` tries every submapper (lowest first) instead of only 0. My first reading was that dropping the check weakened the board-identity test, and the plan said to restore it. Restoring it is what is wrong: for an iNES-1.0 original the parsed submapper is always 0 and the old candidate was always sub 0, so the check could never fail. With the new FFE mapper 6 (added on this branch) it fails the right answer: an iNES-1.0 mapper 6 is latch mode 1, which NES 2.0 can only express as submapper 1, so the only candidate that IS the same board differs in the header field. Measured: with the check restored, `promotion_never_changes_the_board` refuses PAL promotion for all ten mapper-6 layouts, with or without the loop. The board is identified by what it does -- save state, debug description, RAM sizes, mirroring, console / Vs. fields -- which is what the remaining comparisons check. Now said at the site. 2. A database correction TO submapper 0 now applies to an NES 2.0 header (`submapper != 0 || is_nes2`). It can only come from the user overlay: `load_time_entry` already clears the vendored table's submapper for NES 2.0 headers, and `parse_overlay_row` documents an overlay `0` as a deliberate correction. Before, such a correction was silently skipped, so an overlay could not undo a wrong non-zero submapper in an NES 2.0 dump. New test `sub_zero_correction_applies_to_an_nes2_header` (sub 4 -> 0, stays NES 2.0, idempotent); mutation-checked: the pre-import condition fails it. iNES-1.0 behaviour is unchanged (a sub-0 correction still does not promote; the existing test still asserts it). 3. `debug_info()` compared with `==` instead of through `format!("{:?}")` -- same result, no allocation (DebugInfo derives PartialEq). 4. CRC-32 is table-driven (a `const` 256-entry table built at compile time) instead of bit-serial. Same IEEE reflected polynomial; the database lookups that depend on it all still match (gamedb suite). The Seicross paragraph is restored verbatim, followed by a paragraph for change 2. Verified: rustynes-gamedb 24 unit tests + its integration tests pass (the PAL-promotion sweep over 0..=255 x 5 layouts x battery included); clippy -D warnings and fmt clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-gamedb/src/lib.rs | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/rustynes-gamedb/src/lib.rs b/crates/rustynes-gamedb/src/lib.rs index 0f674b9c..29360afe 100644 --- a/crates/rustynes-gamedb/src/lib.rs +++ b/crates/rustynes-gamedb/src/lib.rs @@ -541,6 +541,19 @@ pub fn apply_header_overrides(bytes: &mut [u8], entry: &GameDbEntry) -> bool { if let Some(submapper) = entry.submapper && (submapper != 0 || is_nes2) { + // The submapper field only exists in NES 2.0 headers. A correction that + // sets a non-zero submapper on an iNES-1.0 ROM (e.g. Seicross, whose + // GoodNES dump is iNES-1.0 mapper 185 sub 0 but is really sub 4 — the + // CHR-disable copy-protection variant) must first PROMOTE the header to + // NES 2.0 by setting byte-7 bits 2-3 to `10`, otherwise the core's iNES + // path forces submapper 0 and the override is silently dropped. + // + // A correction TO submapper 0 is applied only to an NES 2.0 header (an + // iNES-1.0 header already reads as 0, so promoting it would change + // nothing but the header). It can only come from the user overlay: + // `load_time_entry` clears the vendored table's submapper for NES 2.0 + // headers, and the overlay's `0` is a deliberate correction + // (`parse_overlay_row`). Before v3.2.0 such a correction was dropped. if !is_nes2 { bytes[7] = (bytes[7] & 0xF3) | 0x08; // Promotion reinterprets header fields the iNES-1.0 spec leaves @@ -704,6 +717,8 @@ fn ines1_as_nes2(bytes: &[u8], region: Region) -> Option<[u8; 16]> { }; let chr_ram = if h.chr_size == 0 { SHIFT_8K } else { 0 }; + // Lowest submapper first, so a board every submapper can express keeps 0. + // See `same_board` for why the submapper itself is not compared. let mut image = bytes.to_vec(); for sub in 0..16 { header[8] = sub << 4; @@ -741,6 +756,14 @@ fn same_board( ), ) -> bool { let ((ca, ma), (cb, mb)) = (a, b); + // No `submapper` comparison, deliberately (v3.2.0). For an iNES 1.0 + // original the parsed submapper is always 0, so against the old sub-0 + // candidate the check could never fail; and some boards read an iNES 1.0 + // header as a NON-zero NES 2.0 submapper -- an iNES 1.0 mapper 6 is FFE + // latch mode 1, which NES 2.0 spells submapper 1 -- so the check refused + // the one candidate that IS the same board. `ines1_as_nes2` therefore + // tries every submapper, lowest first, and the board is identified by + // what it does: the state, the debug description and the sizes below. ca.mapper_id == cb.mapper_id && ca.mirroring == cb.mirroring && ca.console_type == cb.console_type @@ -1345,6 +1368,34 @@ mod tests { assert!(!is_multi_region_title("Gyromite (World).nes")); } + /// A correction TO submapper 0 lands on an NES 2.0 header (it can only come + /// from the user overlay, whose `0` is deliberate). Before v3.2.0 any + /// sub-0 correction was skipped, so an overlay could not undo a wrong + /// non-zero submapper in an NES 2.0 dump. + #[test] + fn sub_zero_correction_applies_to_an_nes2_header() { + let mut rom = vec![0u8; 16 + 32 * 1024 + 8 * 1024]; + rom[0..4].copy_from_slice(b"NES\x1A"); + rom[4] = 2; + rom[5] = 1; + rom[6] = 0xB1; // mapper 185, vertical + rom[7] = 0xB8; // NES 2.0 + rom[8] = 0x40; // submapper 4 + let entry = GameDbEntry { + crc: 0, + region: None, + mapper: None, + submapper: Some(0), + mirroring: None, + title: "overlay sub-0 correction".into(), + }; + assert!(apply_header_overrides(&mut rom, &entry)); + assert_eq!(rom[8] >> 4, 0, "submapper corrected to 0"); + assert_eq!(rom[7] & 0x0C, 0x08, "still NES 2.0"); + // Idempotent: applying it again changes nothing. + assert!(!apply_header_overrides(&mut rom, &entry)); + } + #[test] fn submapper_override_promotes_ines1_to_nes2() { // A non-zero submapper correction on an iNES-1.0 ROM must promote the From 23da71adc0f5c30436663bc7ad434d7f4691a7aa Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:27:57 -0400 Subject: [PATCH 039/104] fix(mappers): mark flash save data dirty on every path that changes it The codex_sec import (bb66f0af) added a save-data dirty flag so the battery writer can skip comparing the whole save image every check period: `Mapper::is_save_data_dirty` / `clear_save_data_dirty` (default `None`, "not tracked"), forwarded by `Nes`, consumed by `BatteryPolicy::differs`, which now returns "no change" without comparing while a board reports `Some(false)`. Only the two self-flashable boards track it -- GTROM (111) and the flashable UNROM 512 (30) -- and for them the saving is real: their save data is the whole 256-512 KiB PRG image. But each board set the flag only on a flash program write. Three other paths change the save data: - `load_state`: restoring a state whose flash differs (rewind across a save, loading a slot taken earlier); - `clear_save_data`: the power-on reset a movie performs; - `save_data_mut`: direct writes, e.g. loading a `.sav`. After any of them the board still reported clean, so the writer skipped the comparison and the change never reached the `.sav` -- including on the forced flush at unload and exit -- until some later program write happened to set the flag. Before the import the writer compared bytes every period and caught all three. `clear_save_data_dirty` is never called by either frontend, so the flag also never clears; the shortcut pays off only until a board's first change. Per the maintainer's instruction (keep the shortcut only if every path that changes save data sets the flag), both boards now set it at the top of all three methods. A spurious `true` costs one comparison; a missing one costs a save, so the boards err that way, and the reasoning is written once per board at `save_data_mut`. Documentation: the trait's "Check dirty" / "Clear dirty" placeholders (and the same two on `Nes`) now state the contract -- `Some(false)` is a promise covering every mutating path -- and that nothing calls the clear hook yet. Gates: - `flash_boards_mark_every_save_data_change_dirty` (both boards): clean when fresh, dirty after `load_state`, `clear_save_data` and `save_data_mut`. Fails on the import's code at "load_state"; mutation-checked by removing the `load_state` mark (fails at "GTROM: load_state"). - `battery_policy::a_board_reporting_clean_skips_the_comparison`: pins the policy half (clean skips; dirty and untracked compare). Verified: mappers lib 1,072 passed; frontend battery_policy 5 passed; clippy -D warnings on core, mappers and frontend; fmt clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/nes.rs | 7 ++- .../rustynes-frontend/src/battery_policy.rs | 16 +++++++ .../rustynes-mappers/src/homebrew_boards.rs | 48 +++++++++++++++++++ crates/rustynes-mappers/src/mapper.rs | 18 ++++++- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index 199fdc37..d511f2db 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -356,12 +356,15 @@ impl Nes { self.bus.mapper.clear_save_data(); } - /// Check dirty + /// Whether the cartridge's save data may have changed since the last + /// [`Self::clear_save_data_dirty`]; `None` when the board does not track it + /// (the caller then compares bytes). See `Mapper::is_save_data_dirty`. + #[must_use] pub fn is_save_data_dirty(&self) -> Option { self.bus.mapper.is_save_data_dirty() } - /// Clear dirty + /// Mark the cartridge's save data clean. See `Mapper::clear_save_data_dirty`. pub fn clear_save_data_dirty(&mut self) { self.bus.mapper.clear_save_data_dirty(); } diff --git a/crates/rustynes-frontend/src/battery_policy.rs b/crates/rustynes-frontend/src/battery_policy.rs index 862b18be..8162b893 100644 --- a/crates/rustynes-frontend/src/battery_policy.rs +++ b/crates/rustynes-frontend/src/battery_policy.rs @@ -190,6 +190,22 @@ mod tests { assert!(p.due(&[7; 4], None, true).is_none()); } + /// v3.2.0: a board that tracks its save data (`Some`) lets the policy + /// skip the byte comparison while it reports clean; untracked (`None`) and + /// dirty (`Some(true)`) boards are compared as before. The skip is only + /// sound because such a board marks every change dirty + /// (`flash_boards_mark_every_save_data_change_dirty` in the mappers). + #[test] + fn a_board_reporting_clean_skips_the_comparison() { + let mut p = BatteryPolicy::new(vec![0; 4]); + assert!( + p.due(&[1; 4], Some(false), true).is_none(), + "clean: skipped" + ); + assert_eq!(p.due(&[1; 4], Some(true), true), Some(vec![1; 4]), "dirty"); + assert_eq!(p.due(&[1; 4], None, true), Some(vec![1; 4]), "untracked"); + } + #[test] fn a_success_moves_the_baseline_and_a_failure_does_not() { let mut p = BatteryPolicy::new(vec![0; 2]); diff --git a/crates/rustynes-mappers/src/homebrew_boards.rs b/crates/rustynes-mappers/src/homebrew_boards.rs index 8e1ef9cd..2cbffe8d 100644 --- a/crates/rustynes-mappers/src/homebrew_boards.rs +++ b/crates/rustynes-mappers/src/homebrew_boards.rs @@ -698,10 +698,17 @@ impl Mapper for Gtrom111 { } fn save_data_mut(&mut self) -> &mut [u8] { + // Every path that can change the save data marks it dirty, not just a + // flash program write: the battery writer skips its comparison while + // this board reports clean (`BatteryPolicy::differs`), so a missed + // path is a lost save. A spurious `true` only costs one comparison. + self.dirty = true; &mut self.flash } fn clear_save_data(&mut self) { + // Marks the save data dirty; see `save_data_mut`. + self.dirty = true; self.flash.copy_from_slice(&self.original); } @@ -834,6 +841,8 @@ impl Mapper for Gtrom111 { } fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + // Marks the save data dirty; see `save_data_mut`. + self.dirty = true; let fixed = 7 + self.chr_ram.len() + self.nt_ram.len(); if data.len() < fixed { return Err(MapperError::WrongLength { @@ -1416,6 +1425,11 @@ impl Mapper for Unrom512M30 { } fn save_data_mut(&mut self) -> &mut [u8] { + // Every path that can change the save data marks it dirty, not just a + // flash program write: the battery writer skips its comparison while + // this board reports clean (`BatteryPolicy::differs`), so a missed + // path is a lost save. A spurious `true` only costs one comparison. + self.dirty = true; if self.flashable() { &mut self.prg_rom } else { @@ -1424,6 +1438,8 @@ impl Mapper for Unrom512M30 { } fn clear_save_data(&mut self) { + // Marks the save data dirty; see `save_data_mut`. + self.dirty = true; if self.flashable() { self.prg_rom.copy_from_slice(&self.original); } @@ -1573,6 +1589,8 @@ impl Mapper for Unrom512M30 { } fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { + // Marks the save data dirty; see `save_data_mut`. + self.dirty = true; let chr_len = if self.chr_is_rom { 0 } else { self.chr.len() }; let fixed = 6 + self.vram.len() + chr_len; if data.len() < fixed { @@ -2179,6 +2197,36 @@ mod tests { assert_eq!(m.cpu_read(0x8123), 0xFF); } + /// v3.2.0: the two flash boards report a dirty flag, and while it reads + /// `Some(false)` the battery writer skips its comparison, so EVERY path + /// that can change the save data must set it -- not only a program + /// write. A restored state, a power-on clear and a direct + /// `save_data_mut` each change the flash without a program write. + #[test] + fn flash_boards_mark_every_save_data_change_dirty() { + fn check(m: &mut dyn Mapper, name: &str) { + assert_eq!(m.is_save_data_dirty(), Some(false), "{name}: fresh"); + let state = m.save_state(); + m.load_state(&state).unwrap(); + assert_eq!(m.is_save_data_dirty(), Some(true), "{name}: load_state"); + m.clear_save_data_dirty(); + m.clear_save_data(); + assert_eq!( + m.is_save_data_dirty(), + Some(true), + "{name}: clear_save_data" + ); + m.clear_save_data_dirty(); + let _ = m.save_data_mut(); + assert_eq!(m.is_save_data_dirty(), Some(true), "{name}: save_data_mut"); + } + check(&mut Gtrom111::new(synth_prg_32k(8), &[]).unwrap(), "GTROM"); + check( + &mut Unrom512M30::new(synth_prg_16k(16), &[], false, true, 1, false).unwrap(), + "UNROM 512 (flashable)", + ); + } + /// The reset a power-on movie performs (`power_on_for_movie` -> /// `clear_save_data`): a flashed UNROM 512 goes back to the image as /// loaded, byte for byte. Neither zeros (a ROM with no program in it) nor diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index c2609d88..5930cdd0 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -740,12 +740,26 @@ pub trait Mapper: Send { self.save_data_mut().fill(0); } - /// Check dirty + /// Whether [`Self::save_data`] may have changed since the last + /// [`Self::clear_save_data_dirty`] (or since the board was built). + /// + /// `None` -- the default -- means "not tracked": the battery writer then + /// compares the bytes, as it always has. A board that returns `Some` lets + /// the writer SKIP that comparison while it reports `Some(false)` + /// (`BatteryPolicy::differs`), which matters for the two self-flashable + /// boards whose save data is the whole multi-hundred-KiB PRG image. So + /// `Some(false)` is a promise: every path that can change the save data + /// -- a program write, [`Self::save_data_mut`], [`Self::clear_save_data`] + /// and [`Self::load_state`] -- must set the flag, or a change is never + /// saved. Added in v3.2.0. fn is_save_data_dirty(&self) -> Option { None } - /// Clear dirty + /// Mark the save data clean (see [`Self::is_save_data_dirty`]). Nothing in + /// the shipped frontends calls it yet, so a tracked board stays dirty after + /// its first change and the comparison resumes; it is the hook a writer + /// would call after a save it knows to be current. Default: nothing. fn clear_save_data_dirty(&mut self) {} /// Start recording the diagnostic FDS read-stream trace (off by default; From 3fb6bc583f1a0386f8c0797630ba539d6b6073fc Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:29:19 -0400 Subject: [PATCH 040/104] docs(apu,core): restore the comments the import dropped, and correct them Three comments the codex_sec import (bb66f0af) rewrote, checked against the vendored NESdev pages rather than restored blind: - `SystemBus::cycle_count`: the import cut "including DMC DMA cycles that the CPU's own `Cpu::cycles` field does not count" -- the one fact that says why the bus keeps its own counter. Restored verbatim. (The rest of that comment, including its existing reference to another emulator's algorithm name, is untouched.) - Pulse `step` and `clock_timer`: the pre-import comment said the sequencer index is "decremented (NESdev wiki)", but the code has always INCREMENTED it. The import's rewording was the accurate one and kept the fact; it dropped the citation. Both sites now say what the wiki says -- the 2A03 sequencer counts down from zero ("APU Pulse": "the counter is initialized to zero but counts downward") -- and why the index counts up: `DUTY_TABLE` lists the outputs in time order. No code change. - Noise mode: the import's field doc called short mode a "93-step mode". The wiki ("APU Noise"): 32767 steps with the mode flag clear, "randomly 93 or 31 steps long otherwise", depending on where the register was when the flag was set. Field doc and module doc now say that; the module doc's mode-0 line also said "15-bit period", which is the register width, not the period (32767 steps). Both predate the import. Comment-only. fmt, clippy -D warnings (apu, core) and rustdoc -D warnings (apu) clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-apu/src/noise.rs | 9 ++++++--- crates/rustynes-apu/src/pulse.rs | 9 +++++---- crates/rustynes-core/src/bus.rs | 3 ++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/rustynes-apu/src/noise.rs b/crates/rustynes-apu/src/noise.rs index 95fea698..87dfd754 100644 --- a/crates/rustynes-apu/src/noise.rs +++ b/crates/rustynes-apu/src/noise.rs @@ -2,8 +2,9 @@ //! //! Per `docs/apu-2a03.md` §Behavior and NESdev wiki "APU Noise" page. //! -//! - Mode 0 (long): feedback = bit 0 XOR bit 1, 15-bit period. -//! - Mode 1 (short): feedback = bit 0 XOR bit 6, 93-bit period. +//! - Mode 0 (long): feedback = bit 0 XOR bit 1, a 32767-step period. +//! - Mode 1 (short): feedback = bit 0 XOR bit 6, a 93- or 31-step period +//! (which one depends on the register's state when the mode is set). use crate::Region; use crate::envelope::Envelope; @@ -24,7 +25,9 @@ pub const PAL_NOISE_PERIODS: [u16; 16] = [ pub struct Noise { /// LFSR (initialized to 1 on power-up; only bottom 15 bits used). pub(crate) lfsr: u16, - /// Mode (false = long / 15-bit, true = short / 93-step mode taking feedback from bit 6). + /// Mode: false = long (feedback from bit 1, 32767 steps), true = short + /// (feedback from bit 6, 93 or 31 steps depending on where the register + /// was when the mode was set; NESdev wiki "APU Noise"). pub(crate) mode: bool, /// Timer reload (from period table). pub(crate) timer_period: u16, diff --git a/crates/rustynes-apu/src/pulse.rs b/crates/rustynes-apu/src/pulse.rs index a07f4387..708bba99 100644 --- a/crates/rustynes-apu/src/pulse.rs +++ b/crates/rustynes-apu/src/pulse.rs @@ -25,8 +25,9 @@ const DUTY_TABLE: [[u8; 8]; 4] = [ pub struct Pulse { /// Duty selection (0..=3). pub(crate) duty: u8, - /// Step index into the duty table (0..=7). Incremented on each timer underflow - /// (the 2A03 hardware down-counter sequence is mapped chronologically into DUTY_TABLE). + /// Step index into the duty table (0..=7), advanced on each timer underflow. + /// The 2A03's sequencer counts DOWN from zero (NESdev wiki "APU Pulse"); + /// `DUTY_TABLE` lists its outputs in time order, so the index counts up. pub(crate) step: u8, /// 11-bit timer reload (from `$4002`/`$4003` low+high writes). pub(crate) timer_period: u16, @@ -126,8 +127,8 @@ impl Pulse { pub fn clock_timer(&mut self) { if self.timer == 0 { self.timer = self.timer_period; - // 8-step duty sequencer. The hardware down-counter sequence is mapped - // chronologically in DUTY_TABLE, so step is incremented here. + // 8-step duty sequencer (NESdev wiki). The hardware counter runs + // down; `DUTY_TABLE` is in time order, so the index runs up. self.step = (self.step + 1) & 0x07; } else { self.timer -= 1; diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 8993e520..64e8e28b 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -4307,7 +4307,8 @@ impl Bus for SystemBus { } fn cycle_count(&self) -> u64 { - // Cumulative bus-side cycle counter. Used + // Cumulative bus-side cycle counter, including DMC DMA cycles + // that the CPU's own `Cpu::cycles` field does not count. Used // by the SH* unstable-store family to detect DMA interrupting // their dummy-read cycle per Mesen2's `SyaSxaAxa` algorithm. self.cycle From ea8752cb3951256100117be9e756c6a6adb3b328 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:35:08 -0400 Subject: [PATCH 041/104] fix(mappers): pin the VRC2 and Fire Hawk accuracy changes, from nesdev The codex_sec import (bb66f0af) carried three behaviour changes the maintainer chose to adopt, red-first and cited (decision of 2026-10-13): VRC2 1-bit mirroring and mappers 23 / 25 submapper 3 built as VRC2 (MAP-R2-04), and Fire Hawk single-screen detection on mapper 71 (MAP-R2-06). The import made them with no source and, for two of the three, no test. Each is now checked against the vendored nesdev page, cited at the site, and pinned by tests that fail without it. VRC2 mirroring (m022_vrc2.rs). nesdev "VRC2 and VRC4", Mirroring Control: "VRC2 supports only vertical or horizontal mirroring. Bit 1 is ignored." The VRC2 had decoded all four values like the VRC4. The import's change and its updated `vrc2_mirroring_control_register` assertions (2 -> vertical, 3 -> horizontal) are kept; the citation is now at the decode. 23 / 25 submapper 3 (lib.rs). nesdev "NES 2.0 submappers": 023:3 is VRC2b, 025:3 is VRC2c, which "should not implement VRC4-only capabilities". Every 23 / 25 image was built as Vrc4. New test `mappers_23_and_25_submapper_3_build_the_vrc2` parses real NES 2.0 headers: submapper 3 gives no IRQ source and H/V-only mirroring, submapper 0 the VRC4 (IRQ, one-screen). Mutation: routing every image to Vrc4 (the pre-import code) fails it ("mapper 23 sub 3: no IRQ"). Fire Hawk (m071_camerica_bf9093.rs + lib.rs). nesdev "INES Mapper 071": only Fire Hawk's BF9097 has the $9000 register; "for compatibility without using a submapper" a board starts with its header mirroring and applies single-screen once $9000-$9FFF is written. nesdev "NES 2.0 submappers": 071:0 hardwired, 071:1 Fire Hawk. The import enabled detection for EVERY submapper; the maintainer's ruling limits it to submapper 0 (which is also every iNES 1.0 image). So: a new configuration field `detect_single_screen` (not saved -- it comes from the header; the detected `has_single_screen` and the mirroring already are), set through a new `Camerica::with_single_screen_detection` builder rather than a new `new()` parameter, because `Camerica` is public API. Submapper 1 keeps the register from power-on; any other submapper ignores $9000 for mirroring and treats it as a bank select, as before. Tests: `submapper_0_detects_fire_hawk_single_screen_on_a_9000_write` (header mirroring first, then SingleScreenB / A, no PRG bank change, survives a state round trip), `submapper_1_has_the_register_from_power_on`, `without_detection_a_9000_write_keeps_the_header_mirroring`, and `parse_wires_detection_from_the_submapper` (iNES 1.0, NES 2.0 sub 1, NES 2.0 sub 2 through `parse`). Mutations: the import's always-detect fails the third; the pre-import never-detect fails the first; wiring `false` in `parse` fails the fourth. Observed in passing, NOT changed: mapper 71 also takes $8000-$BFFF writes as a bank select, where the page puts the bank register at $C000-$FFFF only (the $8000-$BFFF outer bank is mapper 232's). It is long-standing, no staged dump depends on it either way, and changing it is a separate accuracy decision; recorded for the ledger. Output: these can change a frame against v3.1.0 (an iNES Fire Hawk; a VRC2 game writing mirroring values 2 or 3; a 23 / 25 sub-3 image), so they join epoch 4's row in `hardware_options.rs` and the CHANGELOG's Breaking line; v3.2.0 is already epoch 4, so the number does not move. No staged dump's snapshot moved (the full coverage sweep ran with the import's forms of all three). docs/mappers.md rows 23, 25 and 71 updated. Verified: mappers lib 1,077 passed; core epoch tests pass; mappers clippy -D warnings (two "NESdev" doc words lower-cased for doc_markdown, as the rest of the crate writes it) and fmt clean; markdownlint passes on the two documents. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 14 ++- crates/rustynes-core/src/hardware_options.rs | 2 +- crates/rustynes-mappers/src/lib.rs | 10 +- crates/rustynes-mappers/src/m022_vrc2.rs | 41 ++++++++ .../src/m071_camerica_bf9093.rs | 96 ++++++++++++++++++- docs/mappers.md | 6 +- 6 files changed, 160 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e09eb8a4..6448a5f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ cycle-accurate core later replaced. ### Breaking - **Movies and netplay from v3.1.0 are refused.** `EMULATION_EPOCH` rises - from 3 to 4: the mapper 290 and 24C02 fixes below change what the bus sees. + from 3 to 4: the mapper 290, 24C02, VRC2 and *Fire Hawk* fixes below change + what the bus sees. A v3.1.x peer or movie is refused as another emulator version, naming its epoch. Save states are not affected by the epoch. @@ -67,6 +68,17 @@ cycle-accurate core later replaced. 16 and 157), so a random read returns the byte asked for instead of byte `$00`. On the Datach this was the difference between booting and reformatting the save at every power-on. +- **VRC2 mirroring has two settings, not four.** A VRC2 ignores bit 1 of its + mirroring register (nesdev "VRC2 and VRC4"); the one-screen settings are + VRC4-only, and were decoded on the VRC2 too. +- **Mappers 23 and 25 with NES 2.0 submapper 3 are the VRC2** (VRC2b, + VRC2c), built without the VRC4's IRQ and one-screen mirroring. Every 23 / 25 + image was built as a VRC4 before. +- ***Fire Hawk* gets its single-screen mirroring from an iNES 1.0 header.** + Mapper 71 with submapper 0 switches to the BF9097's `$9000` single-screen + register at the first write there, as nesdev's mapper 71 page recommends + without a submapper. Before, only an NES 2.0 submapper 1 header enabled it, + and the `$9000` write selected a PRG bank instead. ## [3.1.0] - 2026-10-08 - "Bellwether" (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it) diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs index 922d1f7b..cbc30051 100644 --- a/crates/rustynes-core/src/hardware_options.rs +++ b/crates/rustynes-core/src/hardware_options.rs @@ -96,7 +96,7 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize; /// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) | /// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) | /// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) | -/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots); a 24C02 keeps its word address across a START (mapper 16, 157); VRC4's IRQ mode bit reads 1 = cycle, 0 = scanline (mappers 21, 23, 25) | +/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots); a 24C02 keeps its word address across a START (mapper 16, 157); VRC4's IRQ mode bit reads 1 = cycle, 0 = scanline (mappers 21, 23, 25); VRC2 mirroring ignores bit 1, and 23 / 25 submapper 3 are built as VRC2; mapper 71 submapper 0 detects *Fire Hawk*'s `$9000` single-screen register | pub const EMULATION_EPOCH: u32 = 4; /// Every host-settable option that changes what the emulated console does. diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs index a740cd2b..6ea91c52 100644 --- a/crates/rustynes-mappers/src/lib.rs +++ b/crates/rustynes-mappers/src/lib.rs @@ -674,11 +674,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> { Box::new(fme7) } 71 => { - // Some Camerica boards (BF9097) have $9000 mirroring control - // (subm 1). Default off. + // Fire Hawk's BF9097 has $9000 single-screen control: NES 2.0 + // submapper 1 has it from power-on; submapper 0 (every iNES 1.0 + // image included) detects it at the first $9000 write, per the + // NESdev page's compatibility advice; other submappers keep the + // header mirroring. let has_single_screen = h.is_nes2 && h.submapper == 1; let cam = Camerica::new(prg_rom, h.mirroring, has_single_screen) - .map_err(|e| RomError::InvalidConfig(e.to_string()))?; + .map_err(|e| RomError::InvalidConfig(e.to_string()))? + .with_single_screen_detection(h.submapper == 0); Box::new(cam) } 75 => { diff --git a/crates/rustynes-mappers/src/m022_vrc2.rs b/crates/rustynes-mappers/src/m022_vrc2.rs index 1dbb86b6..fe579383 100644 --- a/crates/rustynes-mappers/src/m022_vrc2.rs +++ b/crates/rustynes-mappers/src/m022_vrc2.rs @@ -261,6 +261,10 @@ impl Mapper for Vrc2 { match addr & 0xF000 { 0x8000 => self.prg_lo = value & 0x1F, 0x9000 => { + // NESdev "VRC2 and VRC4", Mirroring Control: "VRC2 supports + // only vertical or horizontal mirroring. Bit 1 is ignored." + // (The one-screen values 2 / 3 are VRC4-only.) Until v3.2.0 + // this decoded all four, like the VRC4. self.mirroring = if (value & 1) == 0 { Mirroring::Vertical } else { @@ -469,6 +473,43 @@ mod tests { assert_eq!(m.cpu_read(0x8000), 2, "high bits above 5-bit field ignored"); } + /// NES 2.0 submapper 3 on mappers 23 and 25 is the VRC2 (VRC2b / VRC2c), + /// which "should not implement VRC4-only capabilities" (nesdev "NES 2.0 + /// submappers"): it is built as [`Vrc2`] -- no IRQ, H/V mirroring only -- + /// while submapper 0 stays the VRC4 superset. Until v3.2.0 every 23 / 25 + /// image was built as VRC4. + #[test] + fn mappers_23_and_25_submapper_3_build_the_vrc2() { + fn rom(mapper: u8, sub: u8) -> Vec { + let mut r = vec![0u8; 16 + 128 * 1024 + 128 * 1024]; + r[..4].copy_from_slice(b"NES\x1A"); + r[4] = 8; + r[5] = 16; + r[6] = (mapper & 0x0F) << 4; + r[7] = (mapper & 0xF0) | 0x08; // NES 2.0 + r[8] = sub << 4; + r + } + for mapper in [23u8, 25] { + let (_c, mut vrc2) = crate::parse(&rom(mapper, 3)).expect("parses"); + assert!(!vrc2.caps().irq_source, "mapper {mapper} sub 3: no IRQ"); + vrc2.cpu_write(0x9000, 2); + assert_eq!( + vrc2.current_mirroring(), + Mirroring::Vertical, + "mapper {mapper} sub 3" + ); + let (_c, mut vrc4) = crate::parse(&rom(mapper, 0)).expect("parses"); + assert!(vrc4.caps().irq_source, "mapper {mapper} sub 0: VRC4 IRQ"); + vrc4.cpu_write(0x9000, 2); + assert_eq!( + vrc4.current_mirroring(), + Mirroring::SingleScreenA, + "mapper {mapper} sub 0" + ); + } + } + #[test] fn vrc2_mirroring_control_register() { let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap(); diff --git a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs index e0b98d64..2a1e681e 100644 --- a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs +++ b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs @@ -42,7 +42,13 @@ pub struct Camerica { vram: Box<[u8]>, prg_bank: u8, mirroring: Mirroring, + /// The `$9000` single-screen register is live (Fire Hawk's BF9097). + /// Saved state: detection can turn it on mid-run. has_single_screen: bool, + /// Turn the register on at the first `$9000-$9FFF` write (submapper 0; + /// see [`Self::with_single_screen_detection`]). Configuration from the + /// header, not state, so it is not saved. + detect_single_screen: bool, } impl Camerica { @@ -69,8 +75,26 @@ impl Camerica { prg_bank: 0, mirroring, has_single_screen, + detect_single_screen: false, }) } + + /// Enable Fire Hawk detection: the board keeps its header mirroring + /// until the first write to `$9000-$9FFF`, then behaves as the BF9097, + /// whose register there selects single-screen mirroring. + /// + /// nesdev "INES Mapper 071": only Fire Hawk has the register, it writes + /// it only at `$9000`, and "for compatibility without using a submapper" + /// a board starts with fixed mirroring and applies single-screen once + /// `$9000-$9FFF` is written. NES 2.0 submapper 1 names Fire Hawk + /// outright (the `has_single_screen` constructor flag). Used for + /// submapper 0 only (maintainer ruling, 2026-10-13) -- which covers every + /// iNES 1.0 image, where the submapper reads as 0. Added in v3.2.0. + #[must_use] + pub const fn with_single_screen_detection(mut self, on: bool) -> Self { + self.detect_single_screen = on; + self + } } impl Mapper for Camerica { @@ -97,7 +121,7 @@ impl Mapper for Camerica { fn cpu_write(&mut self, addr: u16, value: u8) { match addr { - 0x9000..=0x9FFF => { + 0x9000..=0x9FFF if self.has_single_screen || self.detect_single_screen => { self.has_single_screen = true; self.mirroring = if value & 0x10 == 0 { Mirroring::SingleScreenA @@ -213,4 +237,74 @@ mod tests { // bank 5 at 16K offset. Let's just check it swaps from 0. assert_ne!(m.cpu_read(0x8000), 0); } + + /// nesdev "INES Mapper 071" / "NES 2.0 submappers" (071): only Fire Hawk's + /// BF9097 has the `$9000` single-screen register, NES 2.0 submapper 1 + /// names it, and "for compatibility without using a submapper" a board + /// starts with its header mirroring and switches to single-screen once + /// `$9000-$9FFF` is written. Maintainer ruling (2026-10-13): that + /// detection applies on submapper 0 only. + #[test] + fn submapper_0_detects_fire_hawk_single_screen_on_a_9000_write() { + let mut m = Camerica::new(synth(16), Mirroring::Vertical, false) + .unwrap() + .with_single_screen_detection(true); + assert_eq!(m.current_mirroring(), Mirroring::Vertical, "header first"); + m.cpu_write(0x9000, 0x10); + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB); + m.cpu_write(0x9000, 0x00); + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA); + assert_eq!(m.cpu_read(0x8000), 0, "a $9000 write is not a bank select"); + // The detected mode survives a state round trip. + let state = m.save_state(); + let mut other = Camerica::new(synth(16), Mirroring::Vertical, false) + .unwrap() + .with_single_screen_detection(true); + other.load_state(&state).unwrap(); + assert_eq!(other.current_mirroring(), Mirroring::SingleScreenA); + } + + /// Submapper 1 (Fire Hawk) has the register from power-on. + #[test] + fn submapper_1_has_the_register_from_power_on() { + let mut m = Camerica::new(synth(16), Mirroring::Vertical, true).unwrap(); + m.cpu_write(0x9000, 0x10); + assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB); + } + + /// The header wiring in `parse`: an iNES 1.0 image (submapper 0) detects, + /// NES 2.0 submapper 1 has the register from power-on, and NES 2.0 + /// submapper 2 (no meaning assigned) keeps its header mirroring. + #[test] + fn parse_wires_detection_from_the_submapper() { + fn rom(nes2_sub: Option) -> Vec { + let mut r = vec![0u8; 16 + 128 * 1024]; + r[..4].copy_from_slice(b"NES\x1A"); + r[4] = 8; // 128 KiB PRG, CHR-RAM + r[6] = 0x71; // mapper 71 low nibble, vertical + r[7] = 0x40; // mapper 71 high nibble + if let Some(sub) = nes2_sub { + r[7] |= 0x08; + r[8] = sub << 4; + } + r + } + for (sub, expect) in [ + (None, Mirroring::SingleScreenB), + (Some(1), Mirroring::SingleScreenB), + (Some(2), Mirroring::Vertical), + ] { + let (_cart, mut m) = crate::parse(&rom(sub)).expect("mapper 71 parses"); + m.cpu_write(0x9000, 0x10); + assert_eq!(m.current_mirroring(), expect, "submapper {sub:?}"); + } + } + + /// Any other submapper keeps its header mirroring: no detection. + #[test] + fn without_detection_a_9000_write_keeps_the_header_mirroring() { + let mut m = Camerica::new(synth(16), Mirroring::Vertical, false).unwrap(); + m.cpu_write(0x9000, 0x10); + assert_eq!(m.current_mirroring(), Mirroring::Vertical); + } } diff --git a/docs/mappers.md b/docs/mappers.md index b53f06b6..5957e374 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -305,14 +305,14 @@ Sorted by number of commercial titles using each mapper. | 19 | — | Namco 163 | 4 | yes (landed) | CPU | banking+IRQ+audio landed (Phase 4 / S3 + Track C2 / Phase 2.2) | Mappy-Land, King of Kings, Final Lap, Rolling Thunder, Megami Tensei II. 1-8 wavetable channels playing 4-bit wavetables from 128 B mapper-internal sound RAM. Address-port at `$F800-$FFFF` (bit 7 = auto-increment, bits 6-0 = 7-bit RAM address) + data-port at `$4800-$4FFF`; per-channel registers at the top of internal RAM (channel 8 at `$78-$7F`, channel 1 at `$40-$47`); 18-bit frequency + 24-bit phase + 6-bit wave-length + nibble-addressed wave start address + 4-bit volume per channel; `$E000` bit 6 = audio-disable. Gated behind the `mapper-audio` cargo feature. Nametables and CIRAM-as-CHR (v2.7.2, `nesdev_wiki/INES_Mapper_019.xhtml`): `$C000/$C800/$D000/$D800` select each nametable quadrant (`< $E0` a read-only 1 KiB CHR-ROM page, `>= $E0` CIRAM A/B by the low bit), powering on as the header's layout; CHR values `>= $E0` map CIRAM as CHR-RAM unless `$E800` bit 6 (`$0000-$0FFF`) or bit 7 (`$1000-$1FFF`) disables it. Save state v3 carries the `$E800` bits; an older blob loads as both halves disabled, the pre-v2.7.2 behaviour. IRQ: a 15-bit up-counter at `$5000` (low) / `$5800` (`EHHH HHHH`, bit 7 = enable), clocked every CPU cycle while enabled, firing and stopping at `$7FFF`; a `$5800` write with bit 7 clear disables it, and the enable reads back in bit 7 (v2.9.8 -- before then every `$5800` write enabled the counter, which broke *Megami Tensei II*'s raster bands). | | 21 | 1, 2 | VRC4a / VRC4c | 4 | — | CPU | landed (Phase 4 / S3) | Konami; Wai Wai World. | | 22 | — | VRC2a | 4 | — | — | landed (Phase 4 / S3) | Konami. | -| 23 | 1-3 | VRC4e / VRC4f / VRC2b | 4 | — | CPU | landed (Phase 4 / S3) | Konami. | +| 23 | 1-3 | VRC4e / VRC4f / VRC2b | 4 | — | CPU | landed (Phase 4 / S3) | Konami. Submapper 3 (VRC2b) is built as the VRC2 from v3.2.0: no IRQ, and H/V mirroring only (the VRC2 ignores bit 1). | | 24 | — | VRC6a | 4 | yes (landed) | CPU | banking+IRQ+audio landed (Phase 4 / S3 + Track C2) | Akumajou Densetsu. 3 extra audio channels (2 pulse + 1 sawtooth) gated behind the `mapper-audio` cargo feature. **Power-on CHR = identity (ASSUMPTION, v2.9.8):** the eight 1 KiB CHR registers (`$D000-$E003`) start at 0-7. NESdev documents no VRC6 power-on state; *Pulsewave Invite* (PD) never writes them and draws its postcard only under this layout (all-zero, the old value, scattered its tiles). The other ten staged mapper 24/26 dumps boot to byte-identical frames either way. Power-on only: `Mapper::reset` keeps the registers, as a soft reset does on almost every board. PRG registers still start at 0 (no documentation or evidence for another value). Applies to mapper 26 as well. | -| 25 | 1-3 | VRC4b / VRC4d / VRC2c | 4 | — | CPU | landed (Phase 4 / S3) | Konami. | +| 25 | 1-3 | VRC4b / VRC4d / VRC2c | 4 | — | CPU | landed (Phase 4 / S3) | Konami. Submapper 3 (VRC2c) is built as the VRC2 from v3.2.0, as for mapper 23. | | 26 | — | VRC6b | 4 | yes (landed) | CPU | banking+IRQ+audio landed (Phase 4 / S3 + Track C2) | Madara, Esper Dream 2. Same channels as VRC6a; A0/A1 swap. Same identity power-on CHR assumption as mapper 24. | | 34 | 0-2 | BNROM / NINA-001 | 4 | — | — | landed (Phase 4 / S2) | Submapper 1 selects NINA-001. | | 66 | — | GxROM | 2 | — | — | landed (Phase 2) | Bus conflict. | | 69 | — | Sunsoft FME-7 | 4 | yes (5B, landed) | CPU | banking+IRQ+audio landed (Phase 4 / S3 + Track C2 / Phase 2.1) | Gimmick! Sunsoft 5B = YM2149F clone: 3 squares + 32-step envelope generator + 17-bit LFSR noise. Two-write protocol via `$C000-$DFFF` (address latch) and `$E000-$FFFF` (data); audio gated behind the `mapper-audio` cargo feature. | -| 71 | — | Camerica BF9093 | 4 | — | — | landed (Phase 4 / S2) | Codemasters titles. | +| 71 | 0, 1 | Camerica BF9093 / BF9097 | 4 | — | — | landed (Phase 4 / S2) | Codemasters titles. Submapper 1 (*Fire Hawk*, BF9097) has the `$9000` single-screen register from power-on; from v3.2.0 submapper 0 (every iNES 1.0 image) keeps its header mirroring until the first `$9000-$9FFF` write, then switches to it, per the NESdev page's compatibility advice (only *Fire Hawk* writes there). Other submappers ignore it. | | 75 | — | VRC1 | 4 | — | — | landed (Phase 4 / S2) | Konami. | | 85 | — | VRC7 | 5 | yes (FM, landed) | CPU | banking+IRQ+**OPLL FM audio landed** | Lagrange Point (JP). Banking + CPU-cycle IRQ identical to VRC6's. OPLL FM via the clean-room `emu2413` port (`crates/rustynes-apu/src/opll.rs`, MIT; ADR-0006 supersedes ADR-0004). | From fcd22cf95e110725d35618bf962f6159a3a3904e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Tue, 13 Oct 2026 23:55:27 -0400 Subject: [PATCH 042/104] perf(apu): find and remove the import's 47% external-audio regression The codex_sec import (bb66f0af) made three changes to the APU hot path, and the external-audio workload (`apu_tick_active_frame_with_external`, the path a VRC6 / 5B / N163 cart drives) ran 47% SLOWER for it. This commit finds the cause by bisection, removes it, keeps the rest, and records every number (docs/performance.md, "v3.2.0 APU"). Measured with `ab_check.sh --package rustynes-apu --target apu_throughput --base c922ed40` (pre-import), each with the A/B/A order-bias control: import as is silent +9.9% active -4.2% external +47.3% one call site (hyp. A) silent -5.0% active -7.3% external +46.5% A + old ring, run 1 silent n.s. active -1.9% external -2.9% A + old ring, run 2 silent n.s. active -2.7% external -6.1% (control drift at most 1.3% except row 1's active, 3.1%) The cause is the reshaped scatter ring write: one full-length slice when the 32-tap window does not wrap, two runs only when it does. The leading hypothesis -- the default-config mix's two call sites (`add_sample_bounded` when the external sample is zero, `add_sample` otherwise) inlining the scatter body twice -- was REFUTED: with a single call site the regression stayed at +46.5%. The external bench feeds a sample that changes every cycle, so it scatters almost every cycle and the scatter's codegen dominates; the silent and active benches rarely scatter and barely moved. Why the one-slice form compiles worse was not investigated (no disassembly); the loop now carries the measurement in a comment so it is not reshaped again. What stays: - `BlipBuf::add_sample_bounded` (skips `is_finite` / `clamp` for the 2A03's own table-bounded mix), now reached through ONE call site: the mix picks `mixed` or `BlipBuf::sanitize(mixed)` and calls it once. `sanitize` is new and is exactly what `add_sample` applied, and `add_sample(v)` is now `add_sample_bounded(sanitize(v))`, so every path is byte-identical by construction. - `OnePole::process_hpf` / `process_lpf` and `FilterChain::process` calling them directly (each stage's kind is fixed at construction). Their doc comments, mangled by the import ("Process HPF", a second "Process filter" below `#[inline]`), are rewritten. Two independent runs agree in sign on every workload with drift below the effect, which is this project's adoption bar: active -1.9% / -2.7%, external -2.9% / -6.1%, silent unchanged. Which kept change earns which part was not separated. Also: `scripts/perf/ab_check.sh` gains `--package` (default `rustynes-core`), since the chip crates' own benches were out of its reach; shellcheck clean. Verified: rustynes-apu 174 tests pass (its golden / byte-identity tests included); clippy and rustdoc -D warnings; the thumbv7em no_std core build. The full suite runs again at the cut. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-apu/src/apu.rs | 14 +++++-- crates/rustynes-apu/src/blip.rs | 67 +++++++++++++++++++------------- crates/rustynes-apu/src/mixer.rs | 16 +++++--- docs/performance.md | 39 +++++++++++++++++++ scripts/perf/ab_check.sh | 17 +++++--- 5 files changed, 111 insertions(+), 42 deletions(-) diff --git a/crates/rustynes-apu/src/apu.rs b/crates/rustynes-apu/src/apu.rs index 1947ca12..ed58f1d2 100644 --- a/crates/rustynes-apu/src/apu.rs +++ b/crates/rustynes-apu/src/apu.rs @@ -1326,11 +1326,17 @@ impl Apu { if self.audio_prov.is_some() { self.record_mix_armed(mixed, external); } - if external == 0.0 { - self.blip.add_sample_bounded(mixed); + // The 2A03's own mix is bounded by its DAC tables, so only a mix + // with an expansion-audio sample in it needs `sanitize` (v3.2.0; + // byte-identical: `add_sample(v)` is `add_sample_bounded( + // sanitize(v))`). The value is chosen and the scatter called once, + // so its body is inlined here once. + let value = if external == 0.0 { + mixed } else { - self.blip.add_sample(mixed); - } + BlipBuf::sanitize(mixed) + }; + self.blip.add_sample_bounded(value); // Nothing follows the general path's `add_sample` but comments -- // the get/put flip moved to `dmc_tick_end` under M-2 -- so there is // no shared tail to run before returning. Verified by reading it, diff --git a/crates/rustynes-apu/src/blip.rs b/crates/rustynes-apu/src/blip.rs index e31415c2..0a4db346 100644 --- a/crates/rustynes-apu/src/blip.rs +++ b/crates/rustynes-apu/src/blip.rs @@ -253,19 +253,31 @@ impl BlipBuf { /// limiter. #[inline] pub fn add_sample(&mut self, value: f32) { - // Defensive saturation. Real mixer output never exceeds ~1.5; the - // clamp catches NaN/Inf from a runaway mapper-audio path so the - // FIR can't propagate non-finite values through the ring. - let value = if value.is_finite() { + self.add_sample_bounded(Self::sanitize(value)); + } + + /// The saturation [`Self::add_sample`] applies: a finite value clamped to + /// `[-4, 4]`, and 0 for NaN / infinity. Real mixer output never exceeds + /// ~1.5; the clamp catches NaN / Inf from a runaway mapper-audio path so + /// the FIR cannot propagate non-finite values through the ring. + /// + /// Separate from the scatter so a caller choosing between a trusted and an + /// untrusted input can pick the VALUE and make one call to + /// [`Self::add_sample_bounded`] (v3.2.0). + #[inline] + #[must_use] + pub fn sanitize(value: f32) -> f32 { + if value.is_finite() { value.clamp(-4.0, 4.0) } else { 0.0 - }; - self.add_sample_bounded(value); + } } - /// Fast-path decimation entry for trusted, bounded inputs (e.g., internal DAC tables). - /// Bypasses the redundant IEEE-754 `is_finite()` and `clamp()` checks. + /// [`Self::add_sample`] for an input the caller knows is finite and within + /// `[-4, 4]` (the 2A03 DAC tables alone): skips the `is_finite` / `clamp` + /// of [`Self::sanitize`], whose result it would leave unchanged, so the + /// output is byte-identical. #[inline] pub fn add_sample_bounded(&mut self, value: f32) { let delta = value - self.held_value; @@ -297,26 +309,27 @@ impl BlipBuf { // unchanged (`slot += delta * coeff`, one touch per slot, mul // then add — no FMA contraction), so output is bit-identical // to the scalar form. + // + // Measured, keep this shape (v3.2.0): a variant that took ONE + // full-length slice when the window does not wrap and the two + // runs only when it does made `apu_tick_active_frame_with_external` + // 46-48% SLOWER in two A/B runs. That workload scatters on almost + // every cycle (its expansion sample changes each cycle), so the + // scatter's codegen dominates it. Reverting to this form recovered + // it (docs/performance.md, "v3.2.0 APU"). let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK; - if start <= RING_SIZE - TAPS { - let dest = &mut self.delta_ring[start..start + TAPS]; - for (slot, &coeff) in dest.iter_mut().zip(row) { - *slot += delta * coeff; - } - } else { - let first = RING_SIZE - start; - for (slot, &coeff) in self.delta_ring[start..RING_SIZE] - .iter_mut() - .zip(&row[..first]) - { - *slot += delta * coeff; - } - for (slot, &coeff) in self.delta_ring[..TAPS - first] - .iter_mut() - .zip(&row[first..]) - { - *slot += delta * coeff; - } + let first = (RING_SIZE - start).min(TAPS); + for (slot, &coeff) in self.delta_ring[start..start + first] + .iter_mut() + .zip(&row[..first]) + { + *slot += delta * coeff; + } + for (slot, &coeff) in self.delta_ring[..TAPS - first] + .iter_mut() + .zip(&row[first..]) + { + *slot += delta * coeff; } } diff --git a/crates/rustynes-apu/src/mixer.rs b/crates/rustynes-apu/src/mixer.rs index de84fbe3..dc84831e 100644 --- a/crates/rustynes-apu/src/mixer.rs +++ b/crates/rustynes-apu/src/mixer.rs @@ -125,8 +125,12 @@ impl OnePole { } } + /// One sample through the filter as a high-pass: + /// `y = coeff * (prev_out + x - prev_in)`. [`Self::process`] picks this or + /// [`Self::process_lpf`] from `is_hpf` per call; [`FilterChain`] knows each + /// stage's kind and calls the right one directly, which drops that branch + /// from every output sample (v3.2.0, byte-identical: the same expression). #[inline] - /// Process HPF pub fn process_hpf(&mut self, x: f32) -> f32 { let y = self.coeff * (self.prev_out + x - self.prev_in); self.prev_in = x; @@ -134,8 +138,9 @@ impl OnePole { y } + /// One sample through the filter as a low-pass: + /// `y = prev_out + coeff * (x - prev_out)`. See [`Self::process_hpf`]. #[inline] - /// Process LPF pub fn process_lpf(&mut self, x: f32) -> f32 { let y = self.prev_out + self.coeff * (x - self.prev_out); self.prev_in = x; @@ -143,8 +148,8 @@ impl OnePole { y } + /// Process one sample, as a high-pass or a low-pass per `is_hpf`. #[inline] - /// Process filter pub fn process(&mut self, x: f32) -> f32 { if self.is_hpf { self.process_hpf(x) @@ -229,9 +234,10 @@ impl FilterChain { } } - /// Process one sample through all three stages. + /// Process one sample through all three stages: two high-passes, then the + /// low-pass. Each stage's kind is fixed at construction, so this calls + /// [`OnePole::process_hpf`] / [`OnePole::process_lpf`] directly. #[inline] - /// Process filter pub fn process(&mut self, x: f32) -> f32 { let a = self.hp1.process_hpf(x); let b = self.hp2.process_hpf(a); diff --git a/docs/performance.md b/docs/performance.md index 35f98014..d7fb1606 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1150,6 +1150,45 @@ hardware-option and power-cycle plumbing, and the Header and identity changes are the candidates. No release-wide speed-up is claimed beyond the one established path. +### v3.2.0 APU — an imported 47% regression, bisected; the import's APU changes adopted without it + +An AI-made remediation branch (`codex_sec`, imported in v3.2.0) reshaped the +APU's hot path in three places: `BlipBuf::add_sample_bounded`, a variant of +`add_sample` without the `is_finite` / `clamp` (called by the default-config +mix when the expansion sample is zero); the scatter's ring write, as one +full-length slice when the 32-tap window does not wrap and two runs only when +it does; and `FilterChain::process` calling each stage's high- or low-pass +directly instead of branching on `is_hpf`. Measured with `ab_check.sh +--package rustynes-apu --target apu_throughput` (the `--package` option was +added for this; the script could reach only `rustynes-core` benches before) +against the pre-import tree `c922ed40`: + +| candidate | `silent_frame` | `active_frame` | `active_frame_with_external` | order-bias control (silent / active / external) | +| --- | --- | --- | --- | --- | +| the import as is | +9.86% | -4.16% | **+47.31%** | -0.67% / +3.12% / +0.53% | +| one scatter call site (hypothesis A) | -5.02% | -7.32% | **+46.53%** | -1.34% / -3.99% / -1.18% | +| A + the pre-import ring write, run 1 | +0.20% (p = 0.17) | -1.86% | -2.92% | +0.06% / +0.18% / +1.24% | +| A + the pre-import ring write, run 2 | -0.20% (p = 0.21) | -2.67% | -6.08% | -0.44% / -0.28% / -1.06% | + +**Cause: the reshaped ring write.** The leading hypothesis -- two call sites +inlining the scatter body twice -- was refuted (row 2: still +46.5%). The +external workload feeds a sample that changes every cycle, so it scatters on +almost every cycle and the scatter's codegen dominates it; the one-slice +variant, though it looks simpler, made it nearly half again slower. Restoring +the two-run form recovered it entirely. Why that form compiles worse was not +investigated (no disassembly was taken); the site now records the measurement +so it is not reshaped again. + +**Decision: ADOPTED, without the ring change.** Kept: `add_sample_bounded`, now +reached through one call site with `BlipBuf::sanitize` choosing the value (so +`add_sample(v)` is literally `add_sample_bounded(sanitize(v))`, byte-identical), +and the filter split. Rows 3 and 4 are two independent runs with the same sign +on every workload and drift below the effect: `active_frame` -1.9% / -2.7%, +`active_frame_with_external` -2.9% / -6.1%, `silent_frame` unchanged. Which of +the two kept changes earns which part was not separated. Byte identity: the +APU suite (174 tests) passes; the full suite is re-run at the cut. Logs are in +the gitignored session scratch, not committed. + ### v2.9.9 campaign — the leads, measured (one adopted) Every row is two `scripts/perf/ab_check.sh` runs on a quiet host. A run starts diff --git a/scripts/perf/ab_check.sh b/scripts/perf/ab_check.sh index 8958c430..6d0faa03 100755 --- a/scripts/perf/ab_check.sh +++ b/scripts/perf/ab_check.sh @@ -41,6 +41,7 @@ # scripts/perf/ab_check.sh --features ppu-idle-line-fast # flag A/B, same tree # scripts/perf/ab_check.sh --bench nes_run_frame_nestest # one workload # scripts/perf/ab_check.sh --target snapshot_restore --bench vs_dual # another bench target +# scripts/perf/ab_check.sh --package rustynes-apu --target apu_throughput # another crate (v3.2.0) # AB_MEASUREMENT_TIME=20 scripts/perf/ab_check.sh # tighter intervals # # CPU pinning (`taskset`) is applied when available: measured on this project's @@ -75,6 +76,9 @@ BENCH_FILTER="" # the dual cabinet's serialize cost (NL-09) but not adjudicate a fix for it: a # serialize change does not move a frame. BENCH_TARGET="full_frame" +# The crate that owns BENCH_TARGET (v3.2.0; `rustynes-core` before, which put +# the chip crates' own benches, such as the APU's, out of reach). +BENCH_PACKAGE="rustynes-core" MEASUREMENT_TIME="${AB_MEASUREMENT_TIME:-10}" WARMUP="${AB_WARMUP_TIME:-2}" @@ -84,6 +88,7 @@ while [[ $# -gt 0 ]]; do --features) FEATURES="$2"; shift 2 ;; --bench) BENCH_FILTER="$2"; shift 2 ;; --target) BENCH_TARGET="$2"; shift 2 ;; + --package) BENCH_PACKAGE="$2"; shift 2 ;; -h|--help) sed -n '2,60p' "${BASH_SOURCE[0]}"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac @@ -143,7 +148,7 @@ bench_args=() bench_args+=(--warm-up-time "${WARMUP}" --measurement-time "${MEASUREMENT_TIME}") echo "==> Adoption A/B (the evidence rule is printed at the end)" -echo " target : ${BENCH_TARGET}" +echo " target : ${BENCH_PACKAGE} / ${BENCH_TARGET}" echo " reference : ${base_sha:0:12} (${BASE_REF})" if [[ -n "${FEATURES}" ]]; then echo " candidate : same tree + features '${FEATURES}'" @@ -159,7 +164,7 @@ echo # reference is the working tree too; only a code A/B needs the worktree. if [[ -n "${FEATURES}" ]]; then echo "==> Benching reference (flag off)" - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --save-baseline ab_ref >/dev/null else echo "==> Benching reference (${base_sha:0:12}) in a throwaway worktree" @@ -167,7 +172,7 @@ else ( cd "${work}/base" CARGO_TARGET_DIR="${REF_TARGET_DIR}" \ - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --save-baseline ab_ref ) >/dev/null fi @@ -177,7 +182,7 @@ echo "==> Benching candidate, compared against the reference" echo feat_args=() [[ -n "${FEATURES}" ]] && feat_args+=(--features "${FEATURES}") -"${PIN[@]}" cargo bench -p rustynes-core "${feat_args[@]}" --bench "${BENCH_TARGET}" -- \ +"${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" "${feat_args[@]}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --baseline ab_ref 2>&1 \ | grep -E "^[a-z][a-z0-9_]*|time:|change:|Performance has|No change" \ | sed 's/^/ /' @@ -200,7 +205,7 @@ echo echo "==> Order-bias control: re-benching the REFERENCE against itself, last" echo if [[ -n "${FEATURES}" ]]; then - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --baseline ab_ref 2>&1 \ | grep -E "^[a-z][a-z0-9_]*|change:|Performance has|No change" \ | sed 's/^/ /' @@ -208,7 +213,7 @@ else ( cd "${work}/base" CARGO_TARGET_DIR="${REF_TARGET_DIR}" \ - "${PIN[@]}" cargo bench -p rustynes-core --bench "${BENCH_TARGET}" -- \ + "${PIN[@]}" cargo bench -p "${BENCH_PACKAGE}" --bench "${BENCH_TARGET}" -- \ "${bench_args[@]}" --baseline ab_ref ) 2>&1 \ | grep -E "^[a-z][a-z0-9_]*|change:|Performance has|No change" \ From c752446fcf4e670488a5a77053cbb81b2ea679f5 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 00:09:06 -0400 Subject: [PATCH 043/104] feat(netplay)!: protocol 8 -- decode input batches; fix the import's netplay The codex_sec import (bb66f0af) changed netplay in seven places. Two were broken outright, two were sound, and three needed a decision; the maintainer chose to keep the batching and have it finished properly. BREAKING: netplay protocol 8, magic "RNE8". A v3.1.x peer ("RNE7") is refused as another emulator version, naming its epoch. 1. Input batching (NET-R2-03) -- finished. The import sent a new `NetMessage::InputBatch` (tag 6) for every retransmission but never decoded it: `from_bytes` returned None for tag 6, so every resend was dropped as malformed and a lost input was never recovered. It also did not bump the protocol, so a v3.1.x peer would have synced and then silently lost resends too. Now: - `from_bytes` decodes it (`decode_input_batch`): player, LE start frame, count byte, exactly `count` inputs, at least one. Empty, truncated or padded payloads are None. - `to_bytes` writes at most `MAX_INPUT_BATCH` (255) inputs under a count that matches. The import wrote the saturated count over the whole slice, so a batch over 255 would have mis-framed. - `PROTOCOL_VERSION` 8, `SYNC_MAGIC` "RNE8"; "RNE7" joins `OLDER_SYNC_MAGICS` as `PROTOCOL_7_SYNC_MAGIC`, and `check_sync` names its epoch (its layout carries one, like protocol 6's). - The session resends one batch per run of consecutive authored frames. The import's loop stopped at the first frame it had not authored and dropped the rest of the window; the per-frame loop it replaced skipped such a frame and kept going, which is restored. A const assert keeps the window (65 frames) inside one batch. - The spectator folds a batch in exactly as the same inputs one by one (the `Input` arm's body moved into `ingest_input`). The import gave it an empty arm, so a spectator lost every retransmission. 2. NET-01 leak -- fixed (again). `RollbackSession::snapshots` was written on every replayed frame -- a full core snapshot -- and never read (rollback restores from its checkpoint) or pruned, so it grew with the session. The import's first commit deleted it; its last restored it. Deleted, with a note at the site. 3. Source filters (NET-R2-07) -- REVERTED, both. `UdpTransport::poll` dropped datagrams not from the adopted `remote`, and the mesh transport those not from a roster address. Either ends a session on a NAT rebinding (the peer's public port changes) or when an IPv4 peer shows up as its IPv4-mapped IPv6 form, and neither authenticates anything: a UDP source is trivially spoofed and the protocol has no authentication for it to stand in for. Adoption stays first-come, so a stray Sync still cannot re-point a session. The reasoning is at `poll`. The plan had said to keep and fix them; handling rebinding needs its own design, and the filter protected nothing in the meantime. 4. Mesh host roster resends -- KEPT, and the comment corrected. The host used to hand off its transport right after the first roster broadcast, and nothing pumps the handshake after that, so the four "resends that ride out loss" were never sent. The import waits for the last one (about 200 ms); the stale "the host can proceed immediately" text is replaced by why it now waits, and the join test stops pumping the joiner once it hands off, as a real caller does. 5. TURN transaction ids (SEC-R2-05) -- the import's `getrandom` call is REVERTED. It ignored the client's seed (breaking deterministic tests) and made the netplay crate depend on getrandom unconditionally, which breaks the default wasm32 build. Real sessions already get OS randomness: the mobile bridge seeds `NatConnect` from getrandom (the import's change there is kept), and that RNG seeds the TURN client. The dependency is removed from rustynes-netplay (Cargo.lock: one line). 6/7. The `connection.rs` / `spectator.rs` match arms for the new variant and a stray "/// Player index" doc line on `Input` are tidied. Gates (each mutation-checked): - `input_batch_encodes_decodes_and_rejects_malformed_payloads`: exact bytes, every truncation, a trailing byte, an empty batch, the 255 cap, and an over-long batch cut to the cap. - `a_protocol_7_peer_is_refused_as_another_version_naming_its_epoch`. - `a_batch_is_the_same_as_its_inputs_sent_one_by_one` (spectator: same frames shown, byte-identical snapshot). Ignoring batches fails it. - `tests/determinism.rs::rollback_recovers_from_packet_loss` -- with the trailing batch never sent it fails, so the existing loss-recovery test now runs through the batch path. Docs: `PROTOCOL_VERSION`'s history gains protocol 8; docs/frontend.md (the netplay spec), docs/netplay-webrtc.md and docs/agents/ci-and-release.md name protocol 8; CHANGELOG Breaking gains the protocol line (and the epoch bullet is reflowed). Verified: rustynes-netplay --all-features 127 passed / 0 failed; clippy -D warnings --all-features; `cargo check -p rustynes-netplay --target wasm32-unknown-unknown` builds again; `cargo check --workspace --all-targets` clean; markdownlint on the four documents. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 9 +- Cargo.lock | 1 - crates/rustynes-netplay/Cargo.toml | 1 - crates/rustynes-netplay/src/connection.rs | 8 +- crates/rustynes-netplay/src/mesh_net.rs | 52 +++---- crates/rustynes-netplay/src/message.rs | 154 ++++++++++++++++-- crates/rustynes-netplay/src/relay.rs | 16 +- crates/rustynes-netplay/src/session.rs | 62 ++++---- crates/rustynes-netplay/src/spectator.rs | 180 ++++++++++++++++------ docs/agents/ci-and-release.md | 2 +- docs/frontend.md | 12 ++ docs/netplay-webrtc.md | 5 +- 12 files changed, 364 insertions(+), 138 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6448a5f9..f5e12999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,12 @@ cycle-accurate core later replaced. - **Movies and netplay from v3.1.0 are refused.** `EMULATION_EPOCH` rises from 3 to 4: the mapper 290, 24C02, VRC2 and *Fire Hawk* fixes below change - what the bus sees. - A v3.1.x peer or movie is refused as another emulator version, naming its - epoch. Save states are not affected by the epoch. + what the bus sees. A v3.1.x peer or movie is refused as another emulator + version, naming its epoch. Save states are not affected by the epoch. +- **Netplay protocol 8 (`"RNE8"`).** Lost inputs are resent as one datagram + per run of frames instead of one per frame. A v3.1.x peer cannot read that + message, so its `"RNE7"` is refused as another emulator version (its epoch + would refuse it anyway). ### Added diff --git a/Cargo.lock b/Cargo.lock index d6b8367a..aa346f7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4495,7 +4495,6 @@ name = "rustynes-netplay" version = "3.1.0" dependencies = [ "futures-util", - "getrandom 0.2.17", "js-sys", "rustynes-core", "serde_json", diff --git a/crates/rustynes-netplay/Cargo.toml b/crates/rustynes-netplay/Cargo.toml index 040136c7..1726d434 100644 --- a/crates/rustynes-netplay/Cargo.toml +++ b/crates/rustynes-netplay/Cargo.toml @@ -31,7 +31,6 @@ signaling-server = ["dep:tokio", "dep:tokio-tungstenite", "dep:futures-util", "d [dependencies] rustynes-core.workspace = true thiserror = { workspace = true, features = ["std"] } -getrandom = "0.2" # v2.6.0 — signaling-server-only async + JSON deps (native, opt-in). The pure # room/relay protocol (the `signaling` module) needs none of these; they back diff --git a/crates/rustynes-netplay/src/connection.rs b/crates/rustynes-netplay/src/connection.rs index 538c83d8..dbf4da74 100644 --- a/crates/rustynes-netplay/src/connection.rs +++ b/crates/rustynes-netplay/src/connection.rs @@ -325,10 +325,16 @@ impl Transport for UdpTransport { } } + // No source filter here, deliberately (v3.2.0 review). A filter on + // `self.remote` was tried and removed: it dropped every datagram after a + // NAT rebinding (the peer's public port changes mid-session) and after an + // IPv4 peer reappears as its IPv4-mapped IPv6 form, ending the session, + // while protecting nothing -- a UDP source is trivially spoofed, and the + // protocol has no authentication for it to stand in for. Adoption + // (`set_remote`) is still first-come, so a stray `Sync` cannot re-point it. fn poll(&mut self) -> Vec { self.poll_with_source() .into_iter() - .filter(|(_msg, from)| self.remote.is_none_or(|peer| peer == *from)) .map(|(msg, _from)| msg) .collect() } diff --git a/crates/rustynes-netplay/src/mesh_net.rs b/crates/rustynes-netplay/src/mesh_net.rs index d4f818b8..457ae43c 100644 --- a/crates/rustynes-netplay/src/mesh_net.rs +++ b/crates/rustynes-netplay/src/mesh_net.rs @@ -140,15 +140,10 @@ impl Transport for UdpMeshTransport { let mut buf = [0u8; RECV_BUF_LEN]; for _ in 0..MAX_DATAGRAMS_PER_POLL { match self.socket.recv_from(&mut buf) { - Ok((len, from)) => { - if !self.peers.contains(&from) { - continue; - } - match NetMessage::from_bytes(&buf[..len]) { - Some(msg) => out.push(msg), - None => self.dropped_invalid = self.dropped_invalid.saturating_add(1), - } - } + Ok((len, _from)) => match NetMessage::from_bytes(&buf[..len]) { + Some(msg) => out.push(msg), + None => self.dropped_invalid = self.dropped_invalid.saturating_add(1), + }, Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, // Windows can surface a prior send's ICMP unreachable as a // ConnectionReset on the next recv — not fatal, keep draining. @@ -408,14 +403,16 @@ impl MeshHost { self.last_roster_sent = Some(now); self.roster_resends -= 1; } + // v3.2.0: hand off only after the LAST resend. The resends exist to + // ride out a lost roster, and they are sent from this pump; once + // the host has its transport nothing pumps this handshake again. + // Until v3.2.0 it handed off after the first broadcast, so the + // other four were never sent and one lost datagram stranded a + // joiner. Cost: the host starts about + // (ROSTER_RESENDS - 1) x ROSTER_RESEND_INTERVAL = 200 ms later. if self.roster_resends > 0 { return Ok(None); } - // After the first broadcast, build + return the host's transport. - // (We still want the joiners to have received it; the resends above - // ride out loss, but the host can proceed immediately — its mesh - // transport simply starts sending Inputs, which also doubles as - // liveness for the joiners.) let socket = self.socket.take().expect("host socket present to hand off"); let peers: Vec = self.joiners.keys().copied().collect(); // We never reach here without at least the resend above; map an I/O @@ -626,12 +623,11 @@ mod tests { let s0 = UdpSocket::bind(loopback()).unwrap(); let s1 = UdpSocket::bind(loopback()).unwrap(); let s2 = UdpSocket::bind(loopback()).unwrap(); - let a0 = s0.local_addr().unwrap(); let a1 = s1.local_addr().unwrap(); let a2 = s2.local_addr().unwrap(); let mut t0 = UdpMeshTransport::new(s0, vec![a1, a2]).unwrap(); - let mut t1 = UdpMeshTransport::new(s1, vec![a0]).unwrap(); - let mut t2 = UdpMeshTransport::new(s2, vec![a0]).unwrap(); + let mut t1 = UdpMeshTransport::new(s1, vec![]).unwrap(); + let mut t2 = UdpMeshTransport::new(s2, vec![]).unwrap(); let msg = NetMessage::Input { player: 0, @@ -648,24 +644,18 @@ mod tests { #[test] fn mesh_transport_drops_malformed() { + let s0 = UdpSocket::bind(loopback()).unwrap(); let s1 = UdpSocket::bind(loopback()).unwrap(); let a1 = s1.local_addr().unwrap(); - let valid_peer = UdpSocket::bind(loopback()).unwrap(); - let a0 = valid_peer.local_addr().unwrap(); - let mut t1 = UdpMeshTransport::new(s1, vec![a0]).unwrap(); + let _t0 = UdpMeshTransport::new(s0, vec![a1]).unwrap(); + let mut t1 = UdpMeshTransport::new(s1, vec![]).unwrap(); - // Blast junk at t1 directly from a valid peer (to test invalid payloads) - // AND from a stray socket (to test invalid sources). + // Blast junk at t1 directly from a stray socket. let stray = UdpSocket::bind(loopback()).unwrap(); - - valid_peer.send_to(&[], a1).unwrap(); - valid_peer.send_to(&[250, 1, 2], a1).unwrap(); - valid_peer - .send_to(&NetMessage::InputAck { frame: 9 }.to_bytes(), a1) - .unwrap(); - + stray.send_to(&[], a1).unwrap(); + stray.send_to(&[250, 1, 2], a1).unwrap(); stray - .send_to(&NetMessage::InputAck { frame: 10 }.to_bytes(), a1) + .send_to(&NetMessage::InputAck { frame: 9 }.to_bytes(), a1) .unwrap(); let got = drain(&mut t1, 1); @@ -836,6 +826,8 @@ mod tests { let mut done = false; let mut j_done = false; for _ in 0..500 { + // The joiner hands off once it has the roster; stop pumping it + // then, as a real caller would (the host keeps resending). if !j_done && let Ok(Some(_)) = j.pump() { j_done = true; } diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs index 7be76769..2ac0db65 100644 --- a/crates/rustynes-netplay/src/message.rs +++ b/crates/rustynes-netplay/src/message.rs @@ -176,9 +176,13 @@ impl SessionIdentity { Err(why) => SyncVerdict::Refuse(why), } } else if NetMessage::OLDER_SYNC_MAGICS.contains(&magic) { - // Protocol 6 carries its epoch (same layout as ours), so the reason - // can name it; protocols 4 and 5 have none. - let theirs = (magic == NetMessage::PROTOCOL_6_SYNC_MAGIC).then_some(peer.epoch); + // Protocols 6 and 7 carry their epoch (same layout as ours), so the + // reason can name it; protocols 4 and 5 have none. + let theirs = matches!( + magic, + NetMessage::PROTOCOL_6_SYNC_MAGIC | NetMessage::PROTOCOL_7_SYNC_MAGIC + ) + .then_some(peer.epoch); SyncVerdict::Refuse(IdentityMismatch::Emulator { ours: self.epoch, theirs, @@ -229,8 +233,16 @@ impl SessionIdentity { /// is one of [`NetMessage::OLDER_SYNC_MAGICS`] and it is refused as another /// emulator version, naming its epoch (protocol 6 carries it). /// +/// `8` (v3.2.0): [`NetMessage::InputBatch`] (tag 6) -- a run of consecutive +/// inputs for one player in one datagram, which the retransmit window now +/// sends instead of one `Input` per frame. The `Sync` layout is unchanged +/// (72 bytes), under the magic `"RNE8"`; a v3.1.x peer's `"RNE7"` joins +/// [`NetMessage::OLDER_SYNC_MAGICS`] and is refused as another emulator +/// version, naming its epoch, because it could not decode a batch: its +/// [`from_bytes`] drops tag 6, so every retransmission would be lost. +/// /// [`from_bytes`]: NetMessage::from_bytes -pub const PROTOCOL_VERSION: u32 = 7; +pub const PROTOCOL_VERSION: u32 = 8; /// Messages exchanged between two peers. /// @@ -253,7 +265,6 @@ pub enum NetMessage { /// The 0-based player index this input belongs to (0..=3). Lets a /// peer route a remote input to the correct controller port; in a /// mesh topology every peer's input carries its own player index. - /// Player index player: u8, /// The frame this input applies to. frame: u32, @@ -261,13 +272,22 @@ pub enum NetMessage { input: u8, }, - /// Batched sequence of consecutive inputs for `player` starting at `start_frame`. + /// v3.2.0 (protocol 8): consecutive confirmed inputs for one player, the + /// first at `start_frame`, in one datagram. The retransmit window + /// ([`crate::RollbackSession`]) sends its resends this way rather than as + /// one [`Self::Input`] per frame. Each element is folded in exactly as an + /// `Input` for frame `start_frame + i` would be. + /// + /// Wire: tag, `player`, `start_frame` (LE `u32`), a count byte, then + /// `count` input bytes. At most [`Self::MAX_INPUT_BATCH`] inputs; a count + /// of 0, a truncated payload or trailing bytes decode to `None`. InputBatch { - /// Player index + /// The 0-based player index the inputs belong to (as for `Input`). player: u8, - /// Start frame + /// The frame of `inputs[0]`. start_frame: u32, - /// Inputs array + /// The inputs for `start_frame`, `start_frame + 1`, ... (1 to + /// [`Self::MAX_INPUT_BATCH`] of them). inputs: Vec, }, @@ -347,7 +367,7 @@ impl NetMessage { /// consider the session synced while the v5 side waited for a reply it /// refuses. Every version compares the magic, so a changed one is /// rejected on both sides. - pub const SYNC_MAGIC: u32 = 0x524E_4537; // "RNE7" + pub const SYNC_MAGIC: u32 = 0x524E_4538; // "RNE8" /// `RustyNES`'s own earlier `Sync` magics: protocol 4 (`"RNES"`, v2.5.x to /// v2.9.7), protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9) and protocol 6 @@ -355,10 +375,11 @@ impl NetMessage { /// is refused with a reason ([`SessionIdentity::check_sync`]). Code that /// needs one protocol names its constant below rather than an index, so /// adding a protocol to this list cannot silently re-point a check. - pub const OLDER_SYNC_MAGICS: [u32; 3] = [ + pub const OLDER_SYNC_MAGICS: [u32; 4] = [ Self::PROTOCOL_4_SYNC_MAGIC, Self::PROTOCOL_5_SYNC_MAGIC, Self::PROTOCOL_6_SYNC_MAGIC, + Self::PROTOCOL_7_SYNC_MAGIC, ]; /// Protocol 4's `Sync` magic, `"RNES"` (v2.5.x to v2.9.7): a 36-byte /// payload with no config hash and no epoch. @@ -369,6 +390,13 @@ impl NetMessage { /// Protocol 6's `Sync` magic, `"RNE6"` (v3.0.0 and v3.0.1): today's layout, /// epoch included, so a refusal can name the peer's epoch. pub const PROTOCOL_6_SYNC_MAGIC: u32 = 0x524E_4536; + /// Protocol 7's `Sync` magic, `"RNE7"` (v3.1.x): today's layout, epoch + /// included. Refused because a protocol 7 peer cannot decode + /// [`Self::InputBatch`]. + pub const PROTOCOL_7_SYNC_MAGIC: u32 = 0x524E_4537; + /// The most inputs one [`Self::InputBatch`] carries: what its count byte + /// can express. The session's retransmit window is far smaller. + pub const MAX_INPUT_BATCH: usize = u8::MAX as usize; // Tag bytes for the hand-rolled encoding. const TAG_INPUT: u8 = 0; @@ -413,10 +441,16 @@ impl NetMessage { start_frame, ref inputs, } => { + // The count byte and the payload must agree, so a batch + // longer than the count can express is cut to + // MAX_INPUT_BATCH rather than written whole under a wrapped + // or saturated count (the receiver would mis-frame it). The + // session never builds one that long. + let inputs = &inputs[..inputs.len().min(Self::MAX_INPUT_BATCH)]; out.push(Self::TAG_INPUT_BATCH); out.push(player); out.extend_from_slice(&start_frame.to_le_bytes()); - out.push(u8::try_from(inputs.len().min(u8::MAX as usize)).unwrap_or(u8::MAX)); + out.push(u8::try_from(inputs.len()).unwrap_or(u8::MAX)); out.extend_from_slice(inputs); } Self::InputAck { frame } => { @@ -500,6 +534,25 @@ impl NetMessage { } } + /// The body of an [`Self::InputBatch`] (after its tag): player, LE start + /// frame, a count byte, then exactly `count` inputs, at least one. An + /// empty batch carries nothing, and a short or long payload is not this + /// encoding; both are `None`. + fn decode_input_batch(rest: &[u8]) -> Option { + let player = *rest.first()?; + let start_frame = u32::from_le_bytes(rest.get(1..5)?.try_into().ok()?); + let count = usize::from(*rest.get(5)?); + let inputs = rest.get(6..)?; + if count == 0 || inputs.len() != count { + return None; + } + Some(Self::InputBatch { + player, + start_frame, + inputs: inputs.to_vec(), + }) + } + /// Parse a buffer produced by [`Self::to_bytes`]. Returns `None` on a /// malformed / truncated / unknown-tag buffer (the UDP transport drops /// such datagrams rather than panicking). @@ -521,13 +574,14 @@ impl NetMessage { let frame = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?); Some(Self::InputAck { frame }) } + Self::TAG_INPUT_BATCH => Self::decode_input_batch(rest), Self::TAG_SYNC => { let magic = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?); match rest.len() { - // Protocols 6 and 7: magic + epoch + two hashes. Exactly + // Protocols 6, 7 and 8: magic + epoch + two hashes. Exactly // that length: a longer payload is a later protocol's, not // this one's with junk on the end. Which protocol it is - // (`"RNE7"` ours, `"RNE6"` v3.0.x) is `check_sync`'s call. + // (`"RNE8"` ours, `"RNE7"` v3.1.x, `"RNE6"` v3.0.x) is `check_sync`'s call. 72 => { let epoch = u32::from_le_bytes(rest.get(4..8)?.try_into().ok()?); let rom_hash: [u8; 32] = rest.get(8..40)?.try_into().ok()?; @@ -782,6 +836,78 @@ mod tests { assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::PROTOCOL_6_SYNC_MAGIC); } + /// v3.2.0 (protocol 8) — a v3.1.x peer's `"RNE7"` `Sync` has our layout, + /// and could even carry our epoch and hashes, but it cannot decode an + /// `InputBatch`, so it is refused as another emulator version, naming + /// the epoch its layout carries. + #[test] + fn a_protocol_7_peer_is_refused_as_another_version_naming_its_epoch() { + let ours = SessionIdentity::new([7u8; 32], [9u8; 32]); + // The same identity as ours, but under protocol 7's magic. + let bytes = NetMessage::Sync { + magic: NetMessage::PROTOCOL_7_SYNC_MAGIC, + identity: ours, + } + .to_bytes(); + let Some(NetMessage::Sync { magic, identity }) = NetMessage::from_bytes(&bytes) else { + panic!("a protocol-7 Sync decodes"); + }; + assert_eq!( + ours.check_sync(magic, &identity), + SyncVerdict::Refuse(IdentityMismatch::Emulator { + ours: ours.epoch, + theirs: Some(ours.epoch), + }) + ); + assert_eq!(NetMessage::SYNC_MAGIC, 0x524E_4538, "\"RNE8\""); + assert_eq!(PROTOCOL_VERSION, 8); + } + + /// v3.2.0 — the `InputBatch` wire form, byte for byte, and its bounds. + #[test] + fn input_batch_encodes_decodes_and_rejects_malformed_payloads() { + let msg = NetMessage::InputBatch { + player: 1, + start_frame: 0x0102_0304, + inputs: vec![0xAA, 0x00, 0x55], + }; + let bytes = msg.to_bytes(); + assert_eq!( + bytes, + [6, 1, 0x04, 0x03, 0x02, 0x01, 3, 0xAA, 0x00, 0x55], + "tag, player, LE frame, count, inputs" + ); + assert_eq!(NetMessage::from_bytes(&bytes), Some(msg)); + + // Truncated anywhere, padded, or empty: refused. + for cut in 1..bytes.len() { + assert_eq!(NetMessage::from_bytes(&bytes[..cut]), None, "cut at {cut}"); + } + let mut long = bytes; + long.push(0); + assert_eq!(NetMessage::from_bytes(&long), None, "trailing byte"); + assert_eq!( + NetMessage::from_bytes(&[6, 0, 0, 0, 0, 0, 0]), + None, + "an empty batch" + ); + + // The largest batch round-trips; a longer one is cut to the cap so + // the count byte and the payload still agree. + let full = NetMessage::InputBatch { + player: 0, + start_frame: 9, + inputs: vec![7; NetMessage::MAX_INPUT_BATCH], + }; + roundtrip(&full); + let over = NetMessage::InputBatch { + player: 0, + start_frame: 9, + inputs: vec![7; NetMessage::MAX_INPUT_BATCH + 10], + }; + assert_eq!(NetMessage::from_bytes(&over.to_bytes()), Some(full)); + } + #[test] fn roster_roundtrips_v4_and_v6() { use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; diff --git a/crates/rustynes-netplay/src/relay.rs b/crates/rustynes-netplay/src/relay.rs index abf9bca6..cbd8d840 100644 --- a/crates/rustynes-netplay/src/relay.rs +++ b/crates/rustynes-netplay/src/relay.rs @@ -340,13 +340,17 @@ impl TurnClient { // ── internal encoders ─────────────────────────────────────────────────── fn next_tx(&mut self) -> TransactionId { + // From the seeded PRNG, so a test's explicit seed fixes the ids. A real + // session's seed is OS randomness: the mobile bridge seeds its + // `NatConnect` from `getrandom`, and that RNG seeds this one. (v3.2.0 + // review: drawing from `getrandom` here instead ignored the seed and + // made the netplay crate depend on it unconditionally, breaking the + // wasm build.) let mut tx = [0u8; 12]; - if getrandom::getrandom(&mut tx).is_err() { - for chunk in tx.chunks_mut(4) { - let r = self.rng.next_u64().to_le_bytes(); - let n = chunk.len(); - chunk.copy_from_slice(&r[..n]); - } + for chunk in tx.chunks_mut(4) { + let r = self.rng.next_u64().to_le_bytes(); + let n = chunk.len(); + chunk.copy_from_slice(&r[..n]); } tx } diff --git a/crates/rustynes-netplay/src/session.rs b/crates/rustynes-netplay/src/session.rs index 12e82061..5896e43d 100644 --- a/crates/rustynes-netplay/src/session.rs +++ b/crates/rustynes-netplay/src/session.rs @@ -67,6 +67,9 @@ pub const MAX_PLAYERS: usize = 4; /// normal conditions, with acks flowing, far fewer (the in-flight latency /// window) are resent. const INPUT_RESEND_WINDOW: u32 = 64; +// The whole retransmit window (`INPUT_RESEND_WINDOW + 1` frames) fits one +// protocol-8 `InputBatch`, whose count byte caps it. +const _: () = assert!((INPUT_RESEND_WINDOW as usize) < NetMessage::MAX_INPUT_BATCH); /// How far past this peer's [`current_frame`](RollbackSession::current_frame) /// a remote `Input.frame` / `Checksum.frame` may sit before the message is @@ -264,19 +267,13 @@ pub struct RollbackSession { /// production build would prune; see the Stage 2/3 handoff). history: Vec, - /// Save-state ring: `snapshots[f]` is the emulator state *before* frame - /// `f` was run (so restoring it and running forward reproduces frame f). - /// Indexed by frame; only the trailing `max_rollback_frames + 1` entries - /// are guaranteed present (older are `None` after pruning). - snapshots: Vec>>, - /// Checksums we have *sent* at confirmed frames, kept so a later remote /// `Checksum` for the same frame can be compared and so we don't re-send. local_checksums: Vec>, /// The canonical hash of the deterministic *entering* state of each /// checksummed frame, recorded by `resync` the moment the frame becomes - /// confirmed (its `snapshots[f]` is then a function of real inputs only). + /// confirmed (its entering state is then a function of real inputs only). /// Because all peers reach byte-identical entering state for a confirmed /// frame, these hashes match across peers — the property that makes the /// checksum exchange a sound desync detector. @@ -345,7 +342,6 @@ impl RollbackSession { remote_ack_frame: None, synced: false, history: Vec::new(), - snapshots: Vec::new(), local_checksums: Vec::new(), confirmed_hashes: Vec::new(), remote_checksums: Vec::new(), @@ -508,9 +504,6 @@ impl RollbackSession { if self.history.len() < need { self.history.resize(need, InputSlot::default()); } - if self.snapshots.len() < need { - self.snapshots.resize(need, None); - } if self.local_checksums.len() < need { self.local_checksums.resize(need, None); } @@ -801,12 +794,11 @@ impl RollbackSession { let mut count = 0; for f in cp_frame..self.current_frame { - // v2.8.0 Phase 3 — encode straight into the ring slot's reused - // buffer (the old path built a full thumbnail-carrying snapshot - // AND cloned it: two ~320 KiB allocations per replayed frame). - let mut slot = self.snapshots[f as usize].take().unwrap_or_default(); - nes.snapshot_core_into(&mut slot); - self.snapshots[f as usize] = Some(slot); + // v3.2.0 (NET-01): no per-frame snapshot is taken here any more. + // A `snapshots[f]` ring was filled on every replayed frame and + // never read -- rollback restores from its checkpoint -- and was + // never pruned either, so it cost a full core snapshot per + // replayed frame and grew with the session. // A confirmed replay enters `f` (f < boundary) on a canonical // base, so its entering *gameplay* state is canonical and cross- // peer-identical. Record a digest for verification. (We digest the @@ -1002,25 +994,40 @@ impl RollbackSession { if start > newest { return; } - let mut batched = Vec::with_capacity((newest - start + 1) as usize); + // v3.2.0 (protocol 8): one `InputBatch` per run of consecutive + // confirmed (locally authored) frames, instead of one `Input` per + // frame. A frame we did not author ends a run and is skipped, as the + // per-frame loop skipped it; the next confirmed frame starts a new + // run, so every authored frame in the window is still resent. (The + // first form of this batching stopped at the first gap and dropped + // the rest of the window.) The window fits one batch (const assert + // beside INPUT_RESEND_WINDOW). + let mut run_start = start; + let mut run: Vec = Vec::new(); for frame in start..=newest { let idx = frame as usize; if idx >= self.history.len() { break; } let slot = self.history[idx].players[lp_idx]; - // Only resend frames we actually authored (confirmed-local). if slot.confirmed { - batched.push(slot.input); - } else { - break; + if run.is_empty() { + run_start = frame; + } + run.push(slot.input); + } else if !run.is_empty() { + self.transport.send(&NetMessage::InputBatch { + player: lp, + start_frame: run_start, + inputs: core::mem::take(&mut run), + }); } } - if !batched.is_empty() { + if !run.is_empty() { self.transport.send(&NetMessage::InputBatch { player: lp, - start_frame: start, - inputs: batched, + start_frame: run_start, + inputs: run, }); } } @@ -1091,12 +1098,11 @@ mod tests { } /// The length every per-frame vector must share (they are grown together - /// by `ensure_frame`); asserting on all six catches a bound that guarded + /// by `ensure_frame`); asserting on all five catches a bound that guarded /// only one call site. - fn per_frame_lens(s: &RollbackSession) -> [usize; 6] { + fn per_frame_lens(s: &RollbackSession) -> [usize; 5] { [ s.history.len(), - s.snapshots.len(), s.local_checksums.len(), s.confirmed_hashes.len(), s.remote_checksums.len(), diff --git a/crates/rustynes-netplay/src/spectator.rs b/crates/rustynes-netplay/src/spectator.rs index 16ad77dd..2528f440 100644 --- a/crates/rustynes-netplay/src/spectator.rs +++ b/crates/rustynes-netplay/src/spectator.rs @@ -373,6 +373,63 @@ impl SpectatorSession { } } + /// Fold one player's input for one frame into the history -- the body of + /// the `Input` arm, shared with `InputBatch` (protocol 8), whose every + /// element is exactly an `Input` for `start_frame + i`. + fn ingest_input(&mut self, player: u8, frame: u32, input: u8) { + // Drop an out-of-range player index. `num_players` is fixed + // by construction (and only ever set by a `Roster` BEFORE + // the first confirmed frame — see the `Roster` arm of `ingest`), + // so a `player` + // beyond it can never become valid: dropping it is correct, + // not merely a best-effort guard, and it keeps a malformed / + // foreign packet from indexing out of bounds or corrupting a + // real player's stream. + if player >= self.config.num_players { + return; + } + // Reject a `frame` that sits implausibly far ahead of the + // frame we are currently confirming. A spectator never shows + // anything beyond the confirmed horizon, so any legitimate + // in-flight `Input.frame` is only a small bounded distance + // ahead. Without this cap a peer-supplied `frame` near + // `u32::MAX` would make `ensure_frame` resize `history` + // unboundedly (an OOM DoS). Dropping it is safe: a real, + // in-window frame is retransmitted by the players' session. + let horizon = self.last_confirmed_frame.map_or(0, |c| c + 1); + if frame > horizon.saturating_add(MAX_SPECTATOR_FRAME_LOOKAHEAD) { + return; + } + // v3.0.0 (T-SPECTATOR-HISTORY): and within the buffer. + // The check above bounds one packet's jump from the + // confirmed horizon, but the horizon itself walks forward + // with every contiguous frame of input, shown or not. + // Without this cap, a peer streaming faster than real + // time, or a stream that never sends a matching `Sync`, + // grew the history without limit. A frame already shown + // is past, and dropped too. + if frame < self.current_frame { + return; + } + if frame - self.current_frame >= self.buffer_cap { + // Dropped, and not resent: remember where playback + // will have to stop (v3.0.0, `stream_lost`). + *self.dropped.entry(frame).or_default() |= 1 << player; + return; + } + // A dropped input that arrives again in the window is + // no longer missing. + if let Some(mask) = self.dropped.get_mut(&frame) { + *mask &= !(1 << player); + if *mask == 0 { + self.dropped.remove(&frame); + } + } + let slot = self.slot_mut(frame); + slot.inputs[player as usize] = input; + slot.arrived |= 1 << player; + } + /// Poll the transport and fold in every relevant message. Receive-only: no /// ack, checksum, or quality reply is ever sent. fn ingest(&mut self) { @@ -397,56 +454,18 @@ impl SpectatorSession { frame, input, } => { - // Drop an out-of-range player index. `num_players` is fixed - // by construction (and only ever set by a `Roster` BEFORE - // the first confirmed frame — see below), so a `player` - // beyond it can never become valid: dropping it is correct, - // not merely a best-effort guard, and it keeps a malformed / - // foreign packet from indexing out of bounds or corrupting a - // real player's stream. - if player >= self.config.num_players { - continue; - } - // Reject a `frame` that sits implausibly far ahead of the - // frame we are currently confirming. A spectator never shows - // anything beyond the confirmed horizon, so any legitimate - // in-flight `Input.frame` is only a small bounded distance - // ahead. Without this cap a peer-supplied `frame` near - // `u32::MAX` would make `ensure_frame` resize `history` - // unboundedly (an OOM DoS). Dropping it is safe: a real, - // in-window frame is retransmitted by the players' session. - let horizon = self.last_confirmed_frame.map_or(0, |c| c + 1); - if frame > horizon.saturating_add(MAX_SPECTATOR_FRAME_LOOKAHEAD) { - continue; - } - // v3.0.0 (T-SPECTATOR-HISTORY): and within the buffer. - // The check above bounds one packet's jump from the - // confirmed horizon, but the horizon itself walks forward - // with every contiguous frame of input, shown or not. - // Without this cap, a peer streaming faster than real - // time, or a stream that never sends a matching `Sync`, - // grew the history without limit. A frame already shown - // is past, and dropped too. - if frame < self.current_frame { - continue; - } - if frame - self.current_frame >= self.buffer_cap { - // Dropped, and not resent: remember where playback - // will have to stop (v3.0.0, `stream_lost`). - *self.dropped.entry(frame).or_default() |= 1 << player; - continue; - } - // A dropped input that arrives again in the window is - // no longer missing. - if let Some(mask) = self.dropped.get_mut(&frame) { - *mask &= !(1 << player); - if *mask == 0 { - self.dropped.remove(&frame); - } + self.ingest_input(player, frame, input); + } + // v3.2.0 (protocol 8): the players' retransmit window sends + // batches; ignoring them (as first written) lost every resend. + NetMessage::InputBatch { + player, + start_frame, + inputs, + } => { + for (offset, input) in (0u32..).zip(inputs) { + self.ingest_input(player, start_frame.saturating_add(offset), input); } - let slot = self.slot_mut(frame); - slot.inputs[player as usize] = input; - slot.arrived |= 1 << player; } NetMessage::Roster { peers } => { // The host's roster tells the spectator how many players to @@ -471,8 +490,7 @@ impl SpectatorSession { // quality hints (it never stalls the players). NetMessage::InputAck { .. } | NetMessage::Checksum { .. } - | NetMessage::Quality { .. } - | NetMessage::InputBatch { .. } => {} + | NetMessage::Quality { .. } => {} } } } @@ -634,6 +652,66 @@ mod tests { assert_eq!(spec.delay_frames(), DELAY); } + /// v3.2.0 (protocol 8): the players resend inputs as `InputBatch`es, and + /// a spectator folds a batch in exactly as the same inputs sent one by + /// one. Ignoring batches (as first written) lost every retransmission. + #[test] + fn a_batch_is_the_same_as_its_inputs_sent_one_by_one() { + const FRAMES: u32 = 6; + let rom = synth_nrom(); + let hash = SessionIdentity::of(&Nes::from_rom(&rom).unwrap()); + let input = + |player: u8, frame: u32| frame.to_le_bytes()[0].wrapping_mul(37) ^ (player << 3); + let mut runs = Vec::new(); + for batched in [false, true] { + let (link, mut feeder) = MemoryTransport::pair(LinkConditions::PERFECT, 3); + let mut spec = SpectatorSession::new( + SpectatorConfig { + num_players: 2, + delay_frames: 0, + }, + link, + hash, + ); + feeder.send(&NetMessage::Sync { + magic: NetMessage::SYNC_MAGIC, + identity: hash, + }); + for player in 0..2u8 { + if batched { + feeder.send(&NetMessage::InputBatch { + player, + start_frame: 0, + inputs: (0..FRAMES).map(|f| input(player, f)).collect(), + }); + } else { + for frame in 0..FRAMES { + feeder.send(&NetMessage::Input { + player, + frame, + input: input(player, frame), + }); + } + } + } + let mut nes = Nes::from_rom(&rom).unwrap(); + let mut shown = Vec::new(); + for _ in 0..FRAMES + 2 { + let out = spec.advance(&mut nes); + if out.produced_frame { + shown.push(out.frame); + } + } + runs.push((shown, nes.snapshot())); + } + assert_eq!( + runs[1].0, + (0..FRAMES).collect::>(), + "every batched frame shows" + ); + assert_eq!(runs[0], runs[1], "same frames, same state"); + } + /// An absurd `delay_frames` is clamped to `MAX_DELAY_FRAMES`, so it cannot /// push the reveal point past the accept window. #[test] diff --git a/docs/agents/ci-and-release.md b/docs/agents/ci-and-release.md index c4a5ae48..37a252b4 100644 --- a/docs/agents/ci-and-release.md +++ b/docs/agents/ci-and-release.md @@ -35,7 +35,7 @@ - **The standard is zero warnings, verified by reading the logs, not the check marks.** A green run hid: rustup's auto-install deprecation, a cargo-ndk NDK-path mismatch, UniFFI's ktlint warning, two Kotlin warnings in generated bindings, 13 MkDocs dead links, a Node DEP0040 inside `deploy-pages`, a Homebrew tap-trust warning on iOS, and criterion's "Unable to complete N samples" on every bench. The sweep that found them: annotations from `repos/{o}/{r}/check-runs/{job}/annotations` for every job of the last 150 runs, plus `gh run view --log` of the latest run of each workflow grepped for `warning|error|deprecat`, minus lines that merely echo a script's own `::error::` text. Suppress only what is in third-party code, by the narrowest switch (`--disable-warning=DEP0040` on one step), and say where it goes away. - **`gh pr ready` seconds after a push loses the ready run (2026-09-23, #548).** The push starts a `synchronize` run whose payload still says `draft: true`, so its heavy jobs skip; `gh pr ready` starts a `ready_for_review` run beside it, and the concurrency group cancelled the READY one. What was left was a green `CI success` that had run no `test`, no `test-roms`, no Android and no Pages build. The gate script caught it only because it listed every job's conclusion instead of reading the rollup. **Push, wait until that push's runs have started, then `gh pr ready`.** If the ready-mode runs show `cancelled`, `gh run rerun ` re-runs them with their original `ready_for_review` payload. That is how #548's ready run was recovered: every job passed on attempt 2. - **A pull request tests Linux only unless its branch is `release/*` (2026-10-03, v2.9.8).** `select matrix` in `ci.yml` gives an ordinary PR `ubuntu-26.04` and `ubuntu-26.04-arm`; macOS and Windows test legs run only on `main`, `release/*` PRs, dispatch and the weekly cron. v2.9.8's release PR was `feat/v2.9.8-cadence`: 36 checks green, merged, then `main` failed on `test (windows-latest)` alone, so Auto Release skipped and the release had no tag. The cause was the checkout, not the code: CRLF on the Windows runner stopped the source-shape tests' split on `"\n#[cfg(test)]\nmod tests {"`, so `every_load_path_installs_a_dual_system_cabinet` counted its own strings (3 against 1). Fixed by `*.rs text eol=lf` in `.gitattributes` (#582, itself on a `release/*` branch so its PR ran Windows). **Name every release PR's branch `release/*`.** -- **A release whose goldens or commercial snapshots moved must raise `rustynes_core::EMULATION_EPOCH` (v3.0.0, ADR 0045).** The epoch is what tells a movie or a netplay peer which emulator *behaviour* it expects. `.rnm` records it (format 5 at v3.0.0, format 6 since v3.1.0), netplay sends it (protocol 6, `"RNE6"`, at v3.0.0; protocol 7, `"RNE7"`, since v3.1.0), and a mismatch is refused, naming both epochs. Forget the bump and a movie recorded on the old behaviour replays its inputs on the new timing, and a mixed-version session desyncs, both silently: the exact failure the epoch was added to prevent. **The trigger is mechanical:** any re-blessed golden, moved `.snap`, or changed AccuracyCoin/blargg/commercial result between the last release and this one. It does not move for byte-identical refactors or performance work, frontend features, or new mapper families (no earlier output exists to differ from). Check it at the release cut, beside the CHANGELOG. **Since v3.1.0 a test enforces it** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment): a moved panel fingerprint fails unless the epoch is above the table's `last_release_epoch`; a probe newly added to the panel is not a moved output and blesses at any epoch (since the PR #594 review). **At every release cut, set `# last_release_epoch` in `crates/rustynes-test-harness/golden/epoch_fingerprint.tsv` to the epoch being shipped**, in the release commit; skip it and the next release can move output under an unchanged epoch without the gate noticing. +- **A release whose goldens or commercial snapshots moved must raise `rustynes_core::EMULATION_EPOCH` (v3.0.0, ADR 0045).** The epoch is what tells a movie or a netplay peer which emulator *behaviour* it expects. `.rnm` records it (format 5 at v3.0.0, format 6 since v3.1.0), netplay sends it (protocol 6, `"RNE6"`, at v3.0.0; protocol 7, `"RNE7"`, at v3.1.0; protocol 8, `"RNE8"`, since v3.2.0), and a mismatch is refused, naming both epochs. Forget the bump and a movie recorded on the old behaviour replays its inputs on the new timing, and a mixed-version session desyncs, both silently: the exact failure the epoch was added to prevent. **The trigger is mechanical:** any re-blessed golden, moved `.snap`, or changed AccuracyCoin/blargg/commercial result between the last release and this one. It does not move for byte-identical refactors or performance work, frontend features, or new mapper families (no earlier output exists to differ from). Check it at the release cut, beside the CHANGELOG. **Since v3.1.0 a test enforces it** (`tests/epoch_fingerprint.rs`, ADR 0045 amendment): a moved panel fingerprint fails unless the epoch is above the table's `last_release_epoch`; a probe newly added to the panel is not a moved output and blesses at any epoch (since the PR #594 review). **At every release cut, set `# last_release_epoch` in `crates/rustynes-test-harness/golden/epoch_fingerprint.tsv` to the epoch being shipped**, in the release commit; skip it and the next release can move output under an unchanged epoch without the gate noticing. - **A runner outage made `CI success` green with nothing tested (2026-10-05, the #585 merge, run 37373901726).** No GitHub-hosted runner ever acquired `detect code changes` ("The job was not acquired by Runner of type hosted even after multiple attempts": no runner name, zero steps, cancelled after 15 minutes), so every gate downstream was skipped. The API reported the job `cancelled`, yet the aggregate's `contains(needs.*.result, 'cancelled')` step was itself skipped, so `needs` did not carry that value. `CI success`, `main`'s only required check, then reported success. **Fixed in v3.0.0** with a step that requires `changes` to have succeeded, and `setup` to have succeeded whenever `changes` found code. A docs-only change still passes, because `setup` skips exactly when `code` is false. The CodeQL "Push on main" run lost `Analyze (rust)` to the same outage. **A default-setup CodeQL run cannot be re-run**: the run and job retry endpoints answer 403, and re-applying the default-setup configuration returns the last run's id rather than starting one. The next push to `main` (or the weekly schedule) is what re-analyses. - **HOLD THE AUTO RELEASE BY CANCELLING MAIN'S CI RUN (v3.0.0).** `release-auto.yml` fires on `workflow_run: CI completed` on `main`, requires `conclusion == 'success'`, and tags that run's `head_sha`. When the maintainer asked for a full docs sweep after #586 had merged, `gh run cancel` on the main CI run of the merge commit left it `cancelled`, so no tag or release was made. The sweep then goes in as its own PR, and that PR's green main run is what tags v3.0.0, on the swept commit. Confirm with `gh release view vX` and `git ls-remote --tags origin vX`. The cancelled run's own `CI success` shows red, which is the outage gate working, not a failure. - **THE FIRST MAJOR BUMP FOUND TWO `bump_release.py` BLIND SPOTS (v3.0.0).** (1) Internal path dependencies carried `version = "2.0.0"`, a caret range no 3.x satisfies, so after the bump `cargo update -w` failed to resolve the workspace. No minor or patch bump can see it. `bump_internal_requirements` now moves them on every MAJOR, and run `cargo update -w` in BOTH workspaces (`crates/rustynes-cosim` has its own lock) after any bump. (2) A CHAIN anchor with its own description (`vX "C" released — . Built on ...`, root `ROADMAP.md`) kept the OLD release's description under the new name; CodeRabbit caught "v3.0.0 ... the tenth release of the v2.9.x line". The selftest had that exact shape and asserted only where the insertion landed. A described head now takes `--lead`, and the old text moves into the chain beside its own release. diff --git a/docs/frontend.md b/docs/frontend.md index 5ed5ebd1..9457f3ca 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -2561,6 +2561,18 @@ settings would otherwise hash differently and be refused as a settings mismatch, the wrong reason. Its `"RNE6"` is now one of RustyNES's older magics, refused as another emulator version naming its epoch. +**Protocol 8 (`"RNE8"`, v3.2.0)** adds `NetMessage::InputBatch` (tag 6): the +retransmit window resends each run of consecutive locally-authored inputs as one +datagram (tag, player, LE start frame, a count byte, then that many inputs; at +most 255, and the window is 65 frames) instead of one `Input` per frame. The +`Sync` layout is unchanged; a v3.1.x peer's `"RNE7"` joins the older magics and +is refused naming its epoch, because it would drop every batch as an unknown +tag and so never recover a lost input. The spectator folds a batch in exactly as +it would the same inputs sent one by one. Neither transport filters datagrams by +source after a peer is adopted: a filter was tried and removed, since it ended +the session on a NAT rebinding or an IPv4-mapped address and authenticated +nothing (`connection.rs` says why at `poll`). + - **Another epoch is refused first**, as another emulator version: `DisconnectReason::EmulatorMismatch` / `NetplayError::EmulatorMismatch` / `MeshError::EmulatorMismatch`, worded by diff --git a/docs/netplay-webrtc.md b/docs/netplay-webrtc.md index 8a7218ee..044eb487 100644 --- a/docs/netplay-webrtc.md +++ b/docs/netplay-webrtc.md @@ -173,8 +173,9 @@ cartridge board and every `HardwareOptions` field), and a difference fails with options (ADR 0044). From v3.0.0 (`PROTOCOL_VERSION` 6, magic `"RNE6"`, ADR 0045) the identity also carries the emulation epoch. Another epoch, or an older RustyNES's `Sync` (`"RNES"`, `"RNE5"`, decoded at exactly its own length -and magic, and since v3.1.0's protocol 7 / `"RNE7"` also v3.0.x's `"RNE6"`, -which names its epoch), fails with `MeshError::EmulatorMismatch` and its counterparts, so +and magic, since v3.1.0's protocol 7 / `"RNE7"` also v3.0.x's `"RNE6"`, and since +v3.2.0's protocol 8 / `"RNE8"` also v3.1.x's `"RNE7"`, each of which names its +epoch), fails with `MeshError::EmulatorMismatch` and its counterparts, so a mixed-version session is refused with a reason instead of timing out. **Only a v3.0.0-or-later peer can give that reason.** A v2.9.9 or older peer ignores the `"RNE6"` magic as a foreign datagram and still times out on its From ddfc5f2500c2f8922e1cb7959b34b6f616a7229a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 00:12:30 -0400 Subject: [PATCH 044/104] fix(frontend): one thread advances the core during netplay The codex_sec import (bb66f0af, NET-R2-06) wanted the UI thread never to block on the core lock during netplay. It did two things, and together they let two threads step one core: - `emu_thread::run_loop` stopped idling on `netplay_paused`, so the emulation thread kept running frames during netplay; - the netplay tick in `App` took the lock with `try_lock` and skipped the tick when it was held. During netplay the winit thread advances the core through the `RollbackSession` (restore, replay, advance). With the emulation thread also calling `run_frame` on the same `Nes` between those calls, each thread's frames land in the other's history: the rollback session's determinism assumption breaks and the peers desync. It also made `try_lock` usually fail, since the emulation thread now held the lock most of the time. Fix, keeping the goal: - `run_loop` idles on `netplay_paused` again, with a comment saying why it must, so exactly one thread advances the core. - The netplay tick keeps `try_lock` (the UI thread never blocks). With the emulation thread idle, the lock is held elsewhere only briefly (a present copy); a skipped tick leaves the previous frame on screen and is one slow frame to the rollback session. - `EmuHandle::try_lock` returned `self.inner.try_lock().ok()`, which maps a POISONED lock to None -- "held" -- so after one panic the netplay tick would have skipped every frame forever. It now recovers the guard exactly as `lock` does, and returns None only for `WouldBlock`. - The import inserted `try_lock` into the middle of `lock`'s doc comment (leaving "a panic on one" dangling above `try_lock` and a reworded copy on `lock`); both docs are rewritten. Kept from the same change: the 5-second peer-liveness check in `NetplayUi::tick` (a stalled session with no newly confirmed frame for 5 s ends with "peer connection lost" instead of freezing). Its `confirmed.unwrap()` is replaced by an `if let ... &&` that cannot panic, and the two new fields are documented. `netplay_ui` is native-only, so `std::time::Instant` is safe there. Gate: `try_lock_recovers_a_poisoned_core_and_refuses_only_a_held_one` (free -> Some; held by another thread -> None; poisoned -> Some). Mutation: restoring `.ok()` fails it at "poisoned: recovered, not None". The idle term itself is not unit-tested (it sits in the thread loop); the comment at the site carries the reason. Verified: rustynes-frontend lib 688 passed; clippy -D warnings for the default feature set and `full`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-frontend/src/app.rs | 7 ++-- crates/rustynes-frontend/src/emu.rs | 42 +++++++++++++++++++--- crates/rustynes-frontend/src/emu_thread.rs | 11 ++++-- crates/rustynes-frontend/src/netplay_ui.rs | 14 ++++++-- 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index a27e33d3..7eb1d933 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -7537,9 +7537,12 @@ impl App { let raw_local = self.input.player1(); let turbo_mask = self.turbo_mask(); let turbo_period = self.config.input.turbo_period; + // v3.2.0: never block the UI thread here. The emulation thread idles + // during netplay, so the core is held only briefly by another thread + // (a present copy); if it is, this tick is skipped and the previous + // frame stays on screen. The rollback session sees that as one slow + // frame. A poisoned lock is recovered, not treated as held. let Some(mut guard) = self.emu.try_lock() else { - // Background EmuThread is actively simulating or rolling back; - // present previous frame rather than blocking the UI thread. return; }; let emu = &mut *guard; diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs index 49511f11..c0595f99 100644 --- a/crates/rustynes-frontend/src/emu.rs +++ b/crates/rustynes-frontend/src/emu.rs @@ -240,13 +240,20 @@ impl EmuHandle { guard } - /// Lock the core. Poisoning is ignored deliberately: a panic on one + /// Lock the core if no other thread holds it, without waiting; `None` + /// only when it is held (v3.2.0, the netplay tick, which skips a frame + /// rather than block the UI thread). A poisoned lock is recovered exactly + /// as [`Self::lock`] recovers it -- reporting it as "held" would make that + /// caller skip every frame forever after one panic. pub fn try_lock(&self) -> Option> { - self.inner.try_lock().ok() + match self.inner.try_lock() { + Ok(guard) => Some(guard), + Err(std::sync::TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()), + Err(std::sync::TryLockError::WouldBlock) => None, + } } - /// Obtain a lock. Panics are trapped but the `PoisonError` is deliberately - /// swallowed, poisoning the thread but unlocking the shell. A crash in one + /// Lock the core. Poisoning is ignored deliberately: a panic on one /// 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> { @@ -2519,6 +2526,33 @@ pub(crate) fn drive_ra( mod tests { use super::*; + /// v3.2.0: `try_lock` is `None` only while another thread holds the core; + /// a poisoned lock is recovered as `lock` recovers it. Treating poison as + /// "held" made the netplay tick skip every frame forever after one panic. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn try_lock_recovers_a_poisoned_core_and_refuses_only_a_held_one() { + let handle = EmuHandle::new(EmuCore::new()); + assert!(handle.try_lock().is_some(), "free: taken"); + + let held = handle.lock(); + let other = handle.clone(); + let busy = std::thread::spawn(move || other.try_lock().is_none()) + .join() + .unwrap(); + assert!(busy, "held by another thread: None"); + drop(held); + + let poisoner = handle.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoner.lock(); + panic!("poison the core lock"); + }) + .join(); + assert!(handle.inner.is_poisoned(), "fixture: the lock is poisoned"); + assert!(handle.try_lock().is_some(), "poisoned: recovered, not None"); + } + /// The shared cost model divides the measured cost over `running + 1` /// frames: depth 0 is one frame, depth 2 is three. #[test] diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs index 2e64dd5d..89337fb6 100644 --- a/crates/rustynes-frontend/src/emu_thread.rs +++ b/crates/rustynes-frontend/src/emu_thread.rs @@ -700,8 +700,15 @@ fn run_loop( } // Idle: no ROM yet, netplay owns the core on the winit thread, or the // user paused emulation from the UX shell. - let idle = - !control.has_rom.load(Ordering::Acquire) || control.user_paused.load(Ordering::Acquire); + // + // `netplay_paused` MUST idle this thread: during netplay the winit + // thread advances the core through the rollback session, and two + // threads stepping one core in turn would desync it. A v3.2.0 import + // dropped this term (to stop the UI thread waiting on the lock); that + // goal is kept by the netplay tick's `try_lock` instead. + let idle = !control.has_rom.load(Ordering::Acquire) + || control.netplay_paused.load(Ordering::Acquire) + || control.user_paused.load(Ordering::Acquire); if idle { // Frame-advance: while user-paused (but with a ROM and not in // netplay), a pending step produces EXACTLY ONE unthrottled frame diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index b7c646d6..f52558f0 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -152,7 +152,11 @@ pub struct NetplayUi { /// captured at connect for the handshake + session /// ([`SessionIdentity::of`]). The peer must announce an identical one. identity: SessionIdentity, + /// When the session last confirmed a new frame (v3.2.0 peer liveness): + /// five seconds of stall past it ends the session with a reason. last_peer_activity: std::time::Instant, + /// The newest confirmed frame seen, so only progress refreshes + /// `last_peer_activity`. last_confirmed_tracker: u32, /// Cached status for the HUD, refreshed each `tick`. status: NetplayStatus, @@ -473,9 +477,13 @@ impl NetplayUi { }; session.add_local_input(local_buttons); - let confirmed = session.last_confirmed_frame(); - if confirmed > Some(self.last_confirmed_tracker) { - self.last_confirmed_tracker = confirmed.unwrap(); + // Peer liveness (v3.2.0): progress is a newly confirmed frame, which + // needs the peer's input. Five seconds without one while the session + // is stalled ends it with a reason instead of freezing silently. + if let Some(confirmed) = session.last_confirmed_frame() + && confirmed > self.last_confirmed_tracker + { + self.last_confirmed_tracker = confirmed; self.last_peer_activity = std::time::Instant::now(); } match session.advance(nes) { From e222720c2d68024e962729a282c9c74ddb7b4f85 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 00:17:24 -0400 Subject: [PATCH 045/104] fix(frontend): review the import's audio, present and save-file changes Five small frontend changes from the codex_sec import (bb66f0af), each read against the code it replaced. Four are kept as they were, with fixes to what they left behind; one needed a guard. 1. Save files are created 0o600 on Unix (maintainer: adopt). `write_atomic` created a NEW file at the umask default (usually 0o644); an existing file's mode was already carried across. Now a new file is owner-only -- battery saves, states, config and recordings -- and an existing one still keeps its mode, so a user who widens one keeps it widened. Documented at `write_atomic` (a "Permissions (Unix)" section), in docs/frontend.md and in the CHANGELOG. Test `a_new_file_is_created_owner_only`; mutation: the pre-import code fails it ("a new file landed at 644"). 2. `PresentBuffer::take_into` swaps instead of copying (kept). The fresh frame's 240 KiB buffer moves into the caller's `out`, and `out`'s old buffer becomes the front slot. That is correct because nothing reads the front slot: `publish` writes only `back`, and the next `take_into` reads `ready`, which a publish refills first. Its doc still said "copy it into `out`"; it now describes the swap and why it is safe. 3. Reverb comb averaging (kept, guarded). `acc /= combs.len() as f32` became `acc *= 0.25`: bit-identical with four combs (dividing by a power of two is exact), and silently wrong the day a comb is added. The delay table is now a `COMB_MS` const and the gain is `COMB_GAIN = 1 / COMB_MS.len()`, computed from it at compile time. 4. The audio ring's slots are `AtomicU32` rather than `AtomicU64` (kept). The removed comment had itself said U32 "would also work"; it also drops a truncating cast on every load. 5. `CacheAligned` 64-byte padding on the ring's head and tail (kept): the producer and the audio callback each write one, and on one cache line they contended. No measurement was taken for (4) or (5); both are layout changes with no effect on the samples. Also corrected while here: docs/frontend.md said "There is no core dirty flag" for battery saves, which stopped being true with the flash boards' flag (previous commit); it now says which boards report one and that every path that changes save data sets it. Verified: rustynes-frontend lib 689 passed (688 + the new test); clippy -D warnings native and wasm32 (`--lib --bins`); markdownlint on both docs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 4 ++++ crates/rustynes-frontend/src/atomic_write.rs | 20 +++++++++++++++++++ crates/rustynes-frontend/src/audio_dsp.rs | 16 +++++++++++---- .../rustynes-frontend/src/present_buffer.rs | 12 ++++++++--- docs/frontend.md | 10 ++++++++-- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e12999..e4d5d401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ cycle-accurate core later replaced. - **Mappers 47, 121, 154, 191 and 243 are Curated**, on local-dump evidence recorded in `docs/compatibility.md` (maintainer decision D23). The tiers are now 51 Core, 121 Curated and 26 BestEffort. +- **New save files are owner-only on Linux and macOS.** A battery save, state, + config or recording that does not exist yet is created with mode `0o600` + rather than the umask default (usually `0o644`); a file that already exists + keeps its mode, so one you widened stays widened. ### Fixed diff --git a/crates/rustynes-frontend/src/atomic_write.rs b/crates/rustynes-frontend/src/atomic_write.rs index a1eab3b5..649e52b8 100644 --- a/crates/rustynes-frontend/src/atomic_write.rs +++ b/crates/rustynes-frontend/src/atomic_write.rs @@ -495,6 +495,14 @@ where /// On failure **before the rename** the scratch file is removed and the existing /// file is left untouched — a stale-but-valid file is the one worth keeping. /// +/// # Permissions (Unix) +/// +/// A file being REPLACED keeps its mode. A file being CREATED is created +/// owner-only, `0o600` (v3.2.0; before, the umask default, typically `0o644`): +/// these are save data, configuration and recordings, which no other user +/// needs to read. A user who wants them shared can widen one, and the widened +/// mode is then kept across every later save. +/// /// # One failure happens AFTER the target is replaced /// /// The parent-directory sync is the last step, and it necessarily runs *after* @@ -889,6 +897,18 @@ mod tests { assert_eq!(mode, 0o600, "mode was widened to {mode:o}"); } + /// v3.2.0: a file that does not exist yet is created owner-only. + #[cfg(unix)] + #[test] + fn a_new_file_is_created_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + let d = tempdir(); + let p = d.join("new.sav"); + write_atomic(&p, b"fresh").expect("write"); + let mode = fs::metadata(&p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "a new file landed at {mode:o}"); + } + /// A mode the UMASK would mask must still land exactly. /// /// This exists because the test above does NOT actually pin property 5. diff --git a/crates/rustynes-frontend/src/audio_dsp.rs b/crates/rustynes-frontend/src/audio_dsp.rs index 3107a82f..9b2fc8e3 100644 --- a/crates/rustynes-frontend/src/audio_dsp.rs +++ b/crates/rustynes-frontend/src/audio_dsp.rs @@ -141,16 +141,24 @@ struct Reverb { allpasses: Vec, } +/// Schroeder's reference comb delays (ms), prime-ish to avoid flutter. +const COMB_MS: [f32; 4] = [29.7, 37.1, 41.1, 43.7]; + +/// The combs' outputs are averaged: `1 / COMB_MS.len()`. With four combs this +/// is 0.25, and multiplying by a power-of-two reciprocal is bit-identical to +/// the division it replaced (v3.2.0); deriving it from the table keeps the two +/// from drifting apart if a comb is added. +#[allow(clippy::cast_precision_loss)] // a handful of combs. +const COMB_GAIN: f32 = 1.0 / COMB_MS.len() as f32; + impl Reverb { /// Build a reverb voiced for `sample_rate`. `room` (0..=1) scales the comb /// feedback (decay time); larger = longer tail. fn new(sample_rate: u32, room: f32) -> Self { let sr = sample_rate.max(1) as f32; - // Schroeder's reference comb delays (ms), prime-ish to avoid flutter. - let comb_ms = [29.7, 37.1, 41.1, 43.7]; let allpass_ms = [5.0, 1.7]; let feedback = Self::room_feedback(room); - let combs = comb_ms + let combs = COMB_MS .iter() .map(|&ms| Comb::new(((ms / 1000.0) * sr) as usize, feedback)) .collect(); @@ -187,7 +195,7 @@ impl Reverb { for c in &mut self.combs { acc += c.process(x); } - acc *= 0.25; + acc *= COMB_GAIN; for a in &mut self.allpasses { acc = a.process(acc); } diff --git a/crates/rustynes-frontend/src/present_buffer.rs b/crates/rustynes-frontend/src/present_buffer.rs index 2fa74142..35bbd587 100644 --- a/crates/rustynes-frontend/src/present_buffer.rs +++ b/crates/rustynes-frontend/src/present_buffer.rs @@ -156,9 +156,15 @@ impl PresentBuffer { self.generation.fetch_add(1, Ordering::Relaxed); } - /// Consumer: if a new frame was published since the last call, swap it - /// into the front slot and copy it into `out` (the present-staging buffer - /// the GPU uploads). + /// Consumer: if a new frame was published since the last call, hand it to + /// `out` (the present-staging buffer the GPU uploads). + /// + /// v3.2.0: by SWAPPING buffers, not copying 240 KiB. The fresh frame's + /// buffer moves into `out`, and `out`'s old buffer becomes the new front + /// slot. Nothing reads the front slot: `publish` writes only `back`, and + /// the next `take_into` reads `ready`, which a publish has refilled by + /// then. So the caller always receives the newest frame, and its old + /// buffer is recycled as storage (its contents are never presented). /// /// Returns `Some(stamp)` when `out` was refreshed, where `stamp` is the /// instant the emulation thread **published that frame** — carried per slot diff --git a/docs/frontend.md b/docs/frontend.md index 9457f3ca..0b38c1ea 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -2352,8 +2352,14 @@ save state. The module is [`battery_save`](../crates/rustynes-frontend/src/batte - **Loaded** at ROM load, under the same `EmuCore` lock that installs the `Nes`, so no frame runs first. Written through `write_atomic` when the live bytes differ from the last write: compared once a second while running, and always - at ROM switch, close and exit. There is no core dirty flag; "clean" means equal - to the last write. + at ROM switch, close and exit. "Clean" means equal to the last write. Since + v3.2.0 a board may also report a dirty flag (`Nes::is_save_data_dirty`); only + the self-flashable GTROM and UNROM 512 do, whose save data is the whole PRG + image, and while one reports clean the comparison is skipped. Each sets the + flag on every path that can change its save data (a program write, a state + load, a power-on clear, `save_data_mut`), since a missed path is a lost save. + A new save file is created owner-only (`0o600`) on Unix; an existing one keeps + its mode (`write_atomic`). - **The periodic write runs with the lock released.** `BatterySave::due_write` copies the changed bytes under a brief `EmuCore` lock; the write, which fsyncs, happens after the lock is dropped, and `written` records the outcome. A failed From 3b88005ca0ace83ef4a7cc3e3593d5b6ef53c462 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 00:44:09 -0400 Subject: [PATCH 046/104] fix(wasm): revert the import's piccolo heap cap and wasm_cheevos rewrite Two wasm-only changes in the codex_sec import (bb66f0af) did not compile, which is why neither was caught by the native gates. Both are reverted to the pre-import code (c922ed40), each for a measured reason. piccolo heap cap (rustynes-script, `PiccoloBackend::drive`). The import added a 64 MiB limit: after each `executor.step`, abort the script if `ctx.gc_metrics().total_allocation()` exceeds it. `gc_metrics` exists only on `Lua`, not on `Context`; the reachable call is `ctx.metrics()` (Context derefs to gc-arena's `Mutation`). With that fixed the cap compiles but does not work, and testing it is how this was found the hard way: - A test doubling a string 40 times (64 MiB after ~26 doublings) was expected to stop at the cap. It did not: one `step` runs many instructions, so the whole loop ran inside a single step, headed for 2^40 bytes, before the cap was ever consulted. Run unconfined it drove the host to 62-65 GB RAM + 30 GB swap and systemd-oomd killed the whole desktop session scope -- twice (00:21:09 and 00:34:30, session-5 and session-26), taking the working session with it. - Re-run inside `systemd-run --scope -p MemoryMax=3G -p MemorySwapMax=0`: the kernel log shows the test process OOM-killed at the 3 GB jail limit after 2.9 s ("Memory cgroup out of memory: Killed process ... (rustynes_script) ... anon-rss:3139288kB"). The 64 MiB cap never fired. - No finer fuel slicing fixes it: an iteration costs a few fuel units and doubles the allocation, so any slice of a few hundred units overshoots without bound. A real limit needs a per-allocation hook, and piccolo 0.3.3 / gc-arena 0.5.3 expose none (Metrics only reports). So there was nothing working to keep. The plan had said to adopt it with `ctx.metrics()`, a doc note and a test; that is superseded by the measurement. Behaviour is v3.1.0's. Scope of the risk: piccolo runs only in the wasm build, where the browser's per-tab memory limit already confines a runaway script to its tab. The allocation test is NOT committed: it cannot pass, and unconfined it takes a desktop down. wasm_cheevos (rustynes-frontend, `BrowserRaSession::do_frame`). The pre-import code erases the read closure's lifetime with a documented, `#[allow(unsafe_code)]`-scoped transmute, sound because the JS callback is invoked only synchronously inside `ra_do_frame` and dropped before the borrow ends (its SAFETY note says so). The import replaced it with a raw `*mut dyn FnMut` plus an `AtomicBool` "alive" flag -- the same erased pointer dereferenced the same way, so no safer -- and it did not compile (`*mut dyn FnMut` defaults to a `'static` object bound, so the borrow "escapes", E0521) and dropped the `allow(unsafe_code)`. Reverted. Verified: rustynes-script with `--no-default-features --features script-wasm`, 15 lib tests (run inside the 3 GB jail as a precaution); clippy -D warnings for rustynes-frontend on wasm32 with `script-wasm`, `browser-cheevos`, and `--no-default-features --features wasm-canvas` -- all three failed or did not build on the import and are clean now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-frontend/src/wasm_cheevos.rs | 22 ++++++------------- crates/rustynes-script/src/piccolo_backend.rs | 14 +++++------- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/crates/rustynes-frontend/src/wasm_cheevos.rs b/crates/rustynes-frontend/src/wasm_cheevos.rs index 4933d50c..1bff0464 100644 --- a/crates/rustynes-frontend/src/wasm_cheevos.rs +++ b/crates/rustynes-frontend/src/wasm_cheevos.rs @@ -202,24 +202,16 @@ impl BrowserRaSession { // and the lint fires -- measured both ways rather than assumed. (Review // on #423 suggested the note alone would do; it does, but only in this // order.) - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; - let alive = Arc::new(AtomicBool::new(true)); - let alive_flag = Arc::clone(&alive); - let read_ptr: *mut (dyn FnMut(u16) -> u8) = read; + #[allow(unsafe_code)] // localized lifetime erasure; see SAFETY below + // SAFETY: the erased pointer is only dereferenced by `ra_do_frame` + // synchronously below, and `closure` is dropped at the end of this + // function, so the call can never outlive the real borrow. + let read_static: &mut (dyn FnMut(u16) -> u8 + 'static) = + unsafe { core::mem::transmute(read) }; let closure = Closure:: u32>::new(move |addr: u32| { - if !alive_flag.load(Ordering::Acquire) { - return 0; - } - // SAFETY: `alive` is set false before `read` goes out of scope, - // preventing post-return invocation from dereferencing the stack pointer. - unsafe { - let reader = &mut *read_ptr; - u32::from(reader((addr & 0xffff) as u16)) - } + u32::from(read_static((addr & 0xffff) as u16)) }); let json = ra_do_frame(closure.as_ref().unchecked_ref()); - alive.store(false, Ordering::Release); drop(closure); parse_unlock_titles(&json) } diff --git a/crates/rustynes-script/src/piccolo_backend.rs b/crates/rustynes-script/src/piccolo_backend.rs index b04570fc..660423f4 100644 --- a/crates/rustynes-script/src/piccolo_backend.rs +++ b/crates/rustynes-script/src/piccolo_backend.rs @@ -698,24 +698,22 @@ impl PiccoloBackend { /// `Ok(())` on completion, [`ScriptError::Budget`] if the fuel ran out, or /// [`ScriptError::Lua`] if the script raised. fn drive(&mut self, executor: &piccolo::StashedExecutor) -> Result<(), ScriptError> { - const MAX_HEAP_BYTES: usize = 64 * 1024 * 1024; // 64 MiB sandbox limit let fuel_budget = fuel_for(self.budget.get()); - let res = self.lua.enter(|ctx| { + let finished = self.lua.enter(|ctx| { let ex = ctx.fetch(executor); let mut fuel = Fuel::with(fuel_budget); loop { + // `step` returns `true` when the executor can make no more + // progress (finished or errored). if ex.step(ctx, &mut fuel) { - break Ok(true); - } - if ctx.gc_metrics().total_allocation() > MAX_HEAP_BYTES { - break Err(ScriptError::Lua("memory limit exceeded".into())); + break true; } if !fuel.should_continue() { - break Ok(false); + break false; } } }); - if res? { + if finished { self.lua .execute::<()>(executor) .map_err(|e| ScriptError::Lua(e.to_string())) From 9273fe7c3905098c79a5ff53f2b1a403a831cea4 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 00:50:38 -0400 Subject: [PATCH 047/104] fix(wasm): browser cheevos read memory through a safe stack closure Supersedes the wasm_cheevos half of 3b88005c, which restored the pre-import `transmute`. That reversed the maintainer's 2026-10-13 decision to "fix and keep" the import's goal for this file -- removing the unsafe lifetime erasure -- and was taken without asking; a side note in review caught it. This does the safe rewrite instead. `BrowserRaSession::do_frame` must hand rcheevos (through `ra_glue.js`) a callback that reads NES memory via a closure borrowing the emulator. `Closure::new` needs a `'static` body, so the old code transmuted the borrowed `&mut dyn FnMut` to `'static` -- sound only by argument (the JS side uses it synchronously and the Closure was dropped before return). The import's replacement kept the same erased pointer behind an AtomicBool and did not compile. wasm-bindgen has the exact tool for this: an imported function may take `&mut dyn FnMut(..)` -- a STACK closure. JS receives a function valid only until the call returns, after which wasm-bindgen invalidates it (a late call throws rather than touching freed memory). `ra_glue.js::ra_do_frame` already installs the callback for one `rc_client_do_frame` and clears it in a `finally`, so it matches. The extern is now `fn ra_do_frame(read_byte: &mut dyn FnMut(u32) -> u32) -> String`, and `do_frame` passes a local closure over `read`: no `unsafe`, no `transmute`, no `Closure`, no `'static` -- the borrow is checked by the compiler. Both doc comments say so. Verification, and its limit: clippy -D warnings for rustynes-frontend on wasm32 with `browser-cheevos` and with the default features; fmt; the wasm built with `--features browser-cheevos` passes through the pinned wasm-bindgen CLI (0.2.129, `--target web`) without error. What it does NOT show: the import is stripped from that wasm, because nothing calls `BrowserRaSession::do_frame` -- the browser RetroAchievements path is scaffolding (ADR 0015; CI names it so), and the only `do_frame` caller in `emu.rs` is the native `rustynes_ra` session. So the new callback could not be exercised end to end; it will be the first time the browser path is wired. (Also noted: `trunk build --features browser-cheevos` builds WITHOUT the feature, because index.html's `data-cargo-features=""` overrides the CLI flag.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-frontend/src/wasm_cheevos.rs | 51 ++++++++------------ 1 file changed, 20 insertions(+), 31 deletions(-) diff --git a/crates/rustynes-frontend/src/wasm_cheevos.rs b/crates/rustynes-frontend/src/wasm_cheevos.rs index 1bff0464..023d28da 100644 --- a/crates/rustynes-frontend/src/wasm_cheevos.rs +++ b/crates/rustynes-frontend/src/wasm_cheevos.rs @@ -80,10 +80,17 @@ extern "C" { #[wasm_bindgen(catch, js_name = ra_load_game)] async fn ra_load_game(rom: &[u8]) -> Result; - /// Drive one frame of achievement logic. `read_byte` is a JS callback that - /// reads one NES CPU-bus byte. Returns a JSON array of this frame's events. + /// Drive one frame of achievement logic. `read_byte` reads one NES CPU-bus + /// byte. Returns a JSON array of this frame's events. + /// + /// `read_byte` is a wasm-bindgen STACK closure (`&mut dyn FnMut`): JS gets + /// a function that is valid only until this call returns, and wasm-bindgen + /// invalidates it then (a later call throws instead of reaching freed + /// memory). That is exactly how `ra_glue.js` uses it -- installed for the + /// one `rc_client_do_frame` and cleared in a `finally` -- so the borrow of + /// the emulator needs no `'static` and no `unsafe` (v3.2.0). #[wasm_bindgen(js_name = ra_do_frame)] - fn ra_do_frame(read_byte: &js_sys::Function) -> String; + fn ra_do_frame(read_byte: &mut dyn FnMut(u32) -> u32) -> String; /// Tear down the client + module. #[wasm_bindgen(js_name = ra_shutdown)] @@ -181,38 +188,20 @@ impl BrowserRaSession { /// events raised this frame (for HUD toasts). No-op (empty) when the module /// is not initialized. /// - /// The `read` closure is wrapped in a JS function for the side module's - /// read-memory trampoline; it is only invoked synchronously inside the - /// `ra_do_frame` call, so the borrow never escapes. + /// `read` reaches the side module's read-memory trampoline as a + /// wasm-bindgen stack closure, valid only for the synchronous + /// `ra_do_frame` call, so the borrow cannot escape -- checked by the + /// compiler and enforced by wasm-bindgen, not argued in a comment. pub fn do_frame(&self, read: &mut dyn FnMut(u16) -> u8) -> Vec { if !self.initialized { return Vec::new(); } - // `Closure::new` requires a `'static` body, but our `read` borrows the - // emulator and only needs to be valid for this synchronous call. - // `ra_do_frame` invokes the JS callback ONLY synchronously while we are - // inside it (the browser is single-threaded and the side module never - // retains the function pointer past the call), then we drop the closure - // before returning. So we erase the borrow's lifetime to `'static`, - // matching the native bridge's `ReadGuard` idiom. - // - // The attribute goes ABOVE the SAFETY note, not between it and the `let`. - // `clippy::undocumented_unsafe_blocks` looks at the line immediately - // preceding the statement, so an attribute sitting there hides the note - // and the lint fires -- measured both ways rather than assumed. (Review - // on #423 suggested the note alone would do; it does, but only in this - // order.) - #[allow(unsafe_code)] // localized lifetime erasure; see SAFETY below - // SAFETY: the erased pointer is only dereferenced by `ra_do_frame` - // synchronously below, and `closure` is dropped at the end of this - // function, so the call can never outlive the real borrow. - let read_static: &mut (dyn FnMut(u16) -> u8 + 'static) = - unsafe { core::mem::transmute(read) }; - let closure = Closure:: u32>::new(move |addr: u32| { - u32::from(read_static((addr & 0xffff) as u16)) - }); - let json = ra_do_frame(closure.as_ref().unchecked_ref()); - drop(closure); + // A stack closure borrowing `read` for this synchronous call only (see + // `ra_do_frame`). Until v3.2.0 this was a `'static` `Closure` over a + // `transmute`d `read`, sound by argument (the callback is used only + // inside the call) but not checkable by the compiler; now it is. + let mut read_u32 = |addr: u32| u32::from(read((addr & 0xffff) as u16)); + let json = ra_do_frame(&mut read_u32); parse_unlock_titles(&json) } From fa425caacf3ad28abd2c3136e26a0788cff08397 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 00:53:57 -0400 Subject: [PATCH 048/104] feat(libretro): slim save states (16 KB), tested; drop unmeasured rewrites From the codex_sec import (bb66f0af). The maintainer chose to adopt slim states after testing restore, run-ahead and an old full-size state; the rest of the import's libretro diff is reverted. Slim states, ADOPTED. `size_serialize_buffer` and `on_serialize` now call `Nes::snapshot_core_into_slim` instead of `snapshot_core_into`: the PPU section is written without its 240 KiB framebuffer (a flag bit in the section's version byte marks it), the encoding the desktop rewind ring has used since v2.3.3. Measured here: 15,922 bytes per state including the device headroom, against 261,682 full -- for run-ahead and RetroArch rollback netplay that is a serialize of every frame. Why dropping the framebuffer is safe: nothing reads it before the next `retro_run` repaints every visible pixel. The one emulation-side reader is the Zapper, and its beam model samples only rows already painted THIS frame (`aperture_is_bright_painted`, which exists precisely because a stale-row read broke Duck Hunt) while the end-of-frame sampler runs after all 240 are painted; libretro serializes between frames. The cost: a state loaded while the frontend is paused shows the old picture until the next frame runs. Tests (abi_tests): - `a_slim_state_restored_into_a_fresh_instance_draws_the_same_next_frame`: serialize at frame 10, restore into a freshly loaded core (run-ahead's second-instance shape; its framebuffer starts blank), run one frame: the captured picture AND the next state equal a straight run's, so every pixel was repainted. Also asserts the state is slim (< 100,000 bytes); mutation -- switching both call sites back to the full encoder -- fails it at 261,682 bytes. - `a_full_size_state_still_loads`: a full blob made with `Nes::snapshot_core_into` on the same ROM is accepted by `retro_unserialize`, and the state read back equals that machine's slim encoding byte for byte. - the existing `a_mid_run_round_trip_serializes_like_a_straight_run` and `no_serialize_reallocates_the_buffer_sized_at_load` pass on slim states. Reverted: the `push_audio` rewrite (resize + `chunks_exact_mut`) and the video blit rewrite (a per-pixel copy function replacing `extend_from_slice` + `as_chunks_mut` swap), plus the two crate-wide `#![allow(clippy::chunks_exact_to_as_chunks)]` / `#![allow(clippy::items_after_statements)]` they needed. No measurement backed either, and a crate-wide allow is not what a local idiom should cost. Behaviour is unchanged by both. Docs: the crate doc names the slim encoder and why it is safe; `size_serialize_buffer`'s "about 260 KB" says 16 KB since v3.2.0; CHANGELOG (Changed). The claim is the full ENCODING still loads -- not compatibility with every earlier release's section versions, which this does not test. Verified: rustynes-libretro 52 passed; clippy -D warnings; fmt; markdownlint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 5 ++ crates/rustynes-libretro/src/abi_tests.rs | 81 +++++++++++++++++++++++ crates/rustynes-libretro/src/lib.rs | 48 ++++++-------- 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d5d401..e834f561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,11 @@ cycle-accurate core later replaced. - **Mappers 47, 121, 154, 191 and 243 are Curated**, on local-dump evidence recorded in `docs/compatibility.md` (maintainer decision D23). The tiers are now 51 Core, 121 Curated and 26 BestEffort. +- **Libretro save states are about 16 KB instead of 260 KB.** The core + no longer stores the 240 KiB framebuffer in a state (`retro_serialize`); + the next frame repaints it before anything reads it, so run-ahead, rewind + and RetroArch netplay behave the same and move far less data. A full-size + state, framebuffer included, is still accepted. - **New save files are owner-only on Linux and macOS.** A battery save, state, config or recording that does not exist yet is created with mode `0o600` rather than the umask default (usually `0o644`); a file that already exists diff --git a/crates/rustynes-libretro/src/abi_tests.rs b/crates/rustynes-libretro/src/abi_tests.rs index a0e17d81..5076bdaa 100644 --- a/crates/rustynes-libretro/src/abi_tests.rs +++ b/crates/rustynes-libretro/src/abi_tests.rs @@ -981,6 +981,87 @@ fn a_mid_run_round_trip_serializes_like_a_straight_run() { ); } +/// v3.2.0: states are SLIM (`Nes::snapshot_core_into_slim`): no 240 KiB +/// framebuffer, which the next `retro_run` repaints. Restored into a FRESH +/// instance -- the run-ahead "second instance" shape, and a framebuffer that +/// starts blank -- the next frame's picture and state must equal a straight +/// run's. A pixel the frame did not repaint would stay blank and differ. +#[test] +fn a_slim_state_restored_into_a_fresh_instance_draws_the_same_next_frame() { + let _frontend = frontend(); + KEEP_FRAME.store(true, SeqCst); + assert!(load(NESTEST, true)); + let size = serialize_size(); + for _ in 0..10 { + run_frame(); + } + let mut mid = vec![0_u8; size]; + assert!(serialize(&mut mid)); + run_frame(); + let straight_frame = held(&LAST_FRAME).clone(); + let mut straight = vec![0_u8; size]; + assert!(serialize(&mut straight)); + unload(); + + assert!(load(NESTEST, true)); + assert!( + unserialize(&mid), + "a slim state loads into a fresh instance" + ); + run_frame(); + let restored_frame = held(&LAST_FRAME).clone(); + let mut restored = vec![0_u8; size]; + assert!(serialize(&mut restored)); + unload(); + KEEP_FRAME.store(false, SeqCst); + + assert!(!straight_frame.is_empty(), "fixture: a frame was captured"); + assert!( + restored_frame == straight_frame, + "the frame after a slim restore differs from a straight run's" + ); + assert!(restored == straight, "and so does the state"); + assert!( + size < 100_000, + "states are slim (no framebuffer): {size} bytes" + ); +} + +/// v3.2.0: a FULL state -- with the framebuffer, as v3.1.0's core wrote -- +/// still loads: the PPU section's version byte marks which encoding it is. +/// Built here with `Nes::snapshot_core_into` on the same ROM, restored +/// through `retro_unserialize`, then read back: the state the core holds is +/// the one the full blob described. +#[test] +fn a_full_size_state_still_loads() { + let _frontend = frontend(); + assert!(load(NESTEST, true)); + let size = serialize_size(); + + let mut nes = rustynes_core::Nes::from_rom(NESTEST).expect("nestest parses"); + for _ in 0..10 { + nes.run_frame(); + } + let mut full = Vec::new(); + nes.snapshot_core_into(&mut full); + let mut slim = Vec::new(); + nes.snapshot_core_into_slim(&mut slim); + assert!( + full.len() > slim.len() + 200_000, + "fixture: the full blob carries the framebuffer" + ); + + assert!(unserialize(&full), "a full-size state is accepted"); + let mut back = vec![0_u8; size]; + assert!(serialize(&mut back)); + unload(); + assert_eq!( + &back[..slim.len()], + &slim[..], + "the restored machine is the one the full state described" + ); +} + #[cfg(target_arch = "x86_64")] #[test] fn a_refused_unserialize_logs_the_reason() { diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs index 0b74f04e..b0e70e94 100644 --- a/crates/rustynes-libretro/src/lib.rs +++ b/crates/rustynes-libretro/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(clippy::chunks_exact_to_as_chunks)] -#![allow(clippy::items_after_statements)] //! RustyNES Libretro Core. //! //! This crate implements the C ABI boundary for the `rustynes-core` engine, exposing @@ -40,7 +38,10 @@ //! `retro_get_memory_data`/`_size` pointer API and the modern //! `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` descriptor registration RetroAchievements' //! `rcheevos` prefers. Save states serialize statically sized binary blobs natively -//! through `Nes::snapshot_core_into` (single console) or `VsDualSystem::snapshot_into` +//! through `Nes::snapshot_core_into_slim` (single console; v3.2.0 -- the PPU is +//! encoded without its 240 KiB framebuffer, which the next `retro_run` repaints +//! before anything reads it; a full-size state, framebuffer included, is still +//! accepted) or `VsDualSystem::snapshot_into` //! (dual cabinet) — this is also what RetroArch's own generic rollback netplay and //! movie/rewind features ride on; RustyNES's bespoke `rustynes-netplay` P2P crate is //! intentionally NOT linked into this core, since it would be redundant with (and @@ -1305,8 +1306,8 @@ impl RustyNesLibretro { /// AUD-17). The encoder is the one `on_serialize` calls, so the size is /// unchanged; what changes is that the first `retro_serialize` -- the /// first frame of run-ahead or rollback netplay -- no longer grows the - /// buffer from nothing during gameplay (about 260 KB; 645 KB for a - /// cabinet). The single console also reserves the device headroom, so a + /// buffer from nothing during gameplay (about 260 KB before v3.2.0's slim + /// states, about 16 KB since; 645 KB for a cabinet). The single console also reserves the device headroom, so a /// Zapper attached later does not reallocate it either. Pinned by /// `abi_tests::no_serialize_reallocates_the_buffer_sized_at_load`. fn size_serialize_buffer(&mut self) { @@ -1607,17 +1608,15 @@ impl RustyNesLibretro { /// is a belt-and-suspenders no-op that keeps that guarantee true even if the /// mono drain buffer is ever grown past 4096. fn push_audio(&mut self, ctx: &mut RunContext, produced: usize) { - let needed = produced * 2; - if self.audio_buffer.len() != needed { - self.audio_buffer.resize(needed, 0); - } - for (&sample, chunk) in self.audio_float_buffer[..produced] - .iter() - .zip(self.audio_buffer.chunks_exact_mut(2)) - { + self.audio_buffer.clear(); + // No-op while capacity already covers `2 * produced` (the common case); + // only ever allocates if a future change enlarges `audio_float_buffer`. + self.audio_buffer.reserve(produced * 2); + for &sample in &self.audio_float_buffer[..produced] { let s16 = sample_to_i16(sample); - chunk[0] = s16; - chunk[1] = s16; + // Duplicate the sample for stereo interleaving (Left, Right). + self.audio_buffer.push(s16); + self.audio_buffer.push(s16); } rust_libretro::contexts::AudioContext::from(&mut *ctx) .batch_audio_samples(&self.audio_buffer); @@ -1641,15 +1640,6 @@ impl RustyNesLibretro { /// The classic single-console present path: 256x240 XRGB8888 + one audio stream. fn run_single(&mut self, ctx: &mut RunContext) { - #[inline] - fn blit_rgba_to_xrgb(dst: &mut [u8], src: &[u8]) { - for (d, s) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { - d[0] = s[2]; // B - d[1] = s[1]; // G - d[2] = s[0]; // R - d[3] = s[3]; // X (alpha) - } - } // No `poll_input` here: `rust-libretro`'s `retro_run` has already // polled the frontend before calling `on_run`, and a second poll per // frame was pure overhead (libretro audit §2.5). @@ -1709,14 +1699,14 @@ impl RustyNesLibretro { } // An FDS disk the game has written to is saved a second later. self.tick_fds_flush(); - const EXPECTED_LEN: usize = NES_W * NES_H * 4; - if self.video_buffer.len() != EXPECTED_LEN { - self.video_buffer.resize(EXPECTED_LEN, 0); - } + self.video_buffer.clear(); let Some(nes) = self.nes.as_ref() else { return; }; - blit_rgba_to_xrgb(&mut self.video_buffer, nes.framebuffer()); + self.video_buffer.extend_from_slice(nes.framebuffer()); + for chunk in self.video_buffer.as_chunks_mut::<4>().0 { + chunk.swap(0, 2); // RGBA8 → XRGB8888 (in-memory B G R X). + } ctx.draw_frame(&self.video_buffer, NES_W as u32, NES_H as u32, NES_W * 4); self.audio_float_buffer.resize(4096, 0.0); From c04da0cafc7295a509f85907f02ed0de86027430 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:15:46 -0400 Subject: [PATCH 049/104] feat(mobile): FDS disk saves under the pristine identity; fix the import The codex_sec import (bb66f0af) changed the Android and iOS apps and the mobile bridge. The maintainer chose to fix and keep the FDS saves and the Android audio change (2026-10-13) and, on the evidence below, to restore the blocking audio write instead (2026-10-14). FDS disk saves (MOB-01) -- REBUILT. FDS games save to the disk itself, and the bridge already exposed the dirty disk (`diskImageBytes`, `diskIsDirty`, `clearDiskDirty`). The import's hosts wrote it to `fds/disk_.fds` and, next launch, loaded THAT file as the ROM. The core hashes the bytes it boots, so the game's identity -- the key of its states, battery file, RA progress, overrides, and of the save file itself -- changed at the first disk write: every store was orphaned and the save was never found again. It also accepted the file only if it had the pristine file's exact size, never set the `.fds` layout's identity, and on iOS wrote a literal `\(sha)` (doubled backslash) as the file name. The Swift called `diskIsDirty` / `diskImageBytes` / `clearDiskDirty` on `EmulatorCore`, which has none of them, so it could not have compiled. Now it follows the desktop's design (`EmuCore::bind_fds_save`, `boot_saved_disk`, NF-17): - Bridge: `NesController::boot_saved_disk(saved)` boots the saved copy with the controller's own BIOS and sample rate and calls `Nes::set_rom_identity(pristine)`, so `rom_identity()` stays the pristine disk's. The parse runs with the lock released, and the result is installed only if the same disk is still loaded. A cartridge, an NSF or an unparsable file is an error and the pristine disk keeps running. Test `a_saved_disk_boots_under_the_pristine_identity` (identity kept, saved bytes loaded, a cartridge refuses); mutation: without `set_rom_identity` it fails ("identity is the pristine disk's"). `disk_image_bytes`' doc no longer tells hosts to load the file as the ROM. - Android: `FdsSaves` -- `filesDir/fds-saves/.fds.sav`, the desktop's layout; `bootIfSaved` after the controller is built (before the battery and auto-resume state, which are newer); `flushIfDirty` on every session-ending path, written with `AtomicFile`, the dirty flag cleared only after the write lands so a failure is retried. - iOS: the same, `Application Support/RustyNES/fds-saves/`; `EmulatorCore` gains the four wrappers; the import's `RomKeyMigration` hunk is removed (FDS saves never existed under an older key). Android audio (AND-R2-01) -- RESTORED to blocking (maintainer). The import switched `writeBytes` to `track.write(byte[], ..., WRITE_NON_BLOCKING)`. That overload is for 8/16-bit PCM, not this PCM_FLOAT track (so most likely an error -- silence), it dropped any partial write, and it removed the blocking that clocks the emulation loop to the audio device. A correct non-blocking design needs its own pacing and rate control and a device to test drift on; it is a follow-up. The v3.1.0 ByteBuffer write is back, with the reason at the site. Android GPU-path frame skip (AND-01) -- REVERTED. With the GPU renderer on, the import skipped the CPU-side RGBA->ARGB conversion and `setPixels`, but screenshots, clips and the external-display mirror read that bitmap, so all three showed a stale frame; and in netplay, where the GPU view is bypassed, the picture itself goes through it. Restored to always converting (v3.1.0's cost); a gated version needs a device to test. Android screen wake lock (MOB-R2-02) -- WIRED. The import added `updateScreenWakeLock` and never called it. The emulation loop now calls it when its running state changes (a controller loaded and not paused), so a gamepad player's screen no longer dims and sleeps mid-game. iOS's `isIdleTimerDisabled` from the import is kept as written. Kept as written: `NesSurfaceView` clearing `pendingSurface` when the surface goes; the bridge's `getrandom` session seed. The bridge's two crate-wide `#![allow]`s (needless_borrow, items_after_statements) are removed and the two sites they hid are fixed instead. Verified: rustynes-mobile 54 tests, clippy -D warnings; Android `./gradlew :app:compileFossDebugKotlin :app:testFossDebugUnitTest` (NDK 30.0.16248370, cargo-ndk + regenerated UniFFI Kotlin bindings, run under an 8 GB memory cap) BUILD SUCCESSFUL, 20 unit tests 0 failures. NOT verified: the Swift -- this host has no Swift toolchain, so the iOS half is uncompiled until the `ios` CI job or a Mac builds it; and no device ran any of it. docs/android.md and docs/ios.md describe the saves (iOS marked uncompiled); CHANGELOG (Fixed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 7 + .../com/doublegate/rustynes/MainActivity.kt | 128 ++++++++++++------ crates/rustynes-mobile/src/lib.rs | 116 +++++++++++++++- docs/android.md | 13 +- docs/ios.md | 8 +- ios/RustyNES/AppModel.swift | 77 ++++++----- ios/RustyNES/EmulatorCore.swift | 11 ++ ios/RustyNES/RomIdentity.swift | 4 - 8 files changed, 279 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e834f561..792c4bf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,13 @@ cycle-accurate core later replaced. ### Fixed +- **FDS disk games keep their saves on Android and iOS.** The game's writes to + its disk are saved when the app is backgrounded, a game is switched or closed, + and the saved disk boots next time under the original game's identity, so its + save states, RetroAchievements progress and settings are still found. Before, + a disk game's own save was lost on close. (The iOS half is not yet compiled.) +- **The Android screen stays on during play** and sleeps again when paused or + closed; with a gamepad it used to dim and sleep mid-game. - **Mapper 290 (*Asder 20-in-1*) boots.** Its 32 KiB PRG mode read the upper half through the 16 KiB window, so the reset vector came from the wrong bank. diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt index 9bb71ccd..1b688340 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt @@ -307,6 +307,8 @@ class MainActivity : AppCompatActivity() { } } + /** v3.2.0: hold `FLAG_KEEP_SCREEN_ON` while a game runs (set from the + * emulation loop on a change of its running state). */ fun updateScreenWakeLock(running: Boolean) { runOnUiThread { if (running) { @@ -574,15 +576,7 @@ class EmulatorHandle { runCatching { saver.flushIfChanged { ctrl.batteryRam() } } .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } } - val sha = romSha - if (sha != null && ctrl.diskIsDirty()) { - runCatching { - val fdsDir = java.io.File(context.filesDir, "fds") - fdsDir.mkdirs() - java.io.File(fdsDir, "disk_$sha.fds").writeBytes(ctrl.diskImageBytes()) - ctrl.clearDiskDirty() - }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } - } + romSha?.let { sha -> FdsSaves.flushIfDirty(context, ctrl, sha) } } /** Lowercase hex SHA-256 of the loaded ROM — the save-state directory key. */ @@ -759,6 +753,59 @@ private class PreparedRom( val batteryNotice: String?, ) + +/** + * v3.2.0 -- an FDS disk's saved image: `filesDir/fds-saves/.fds.sav`, + * the desktop's layout (`EmuCore::bind_fds_save`). FDS games save to the disk + * itself, so the whole image is the save. + * + * The file is keyed by the PRISTINE disk's identity and booted through + * [NesController.bootSavedDisk], which keeps that identity. Loading the saved + * file as the ROM instead (the first v3.2.0 draft) changed the game's identity + * at its first disk write and orphaned its states, RA progress and overrides. + */ +internal object FdsSaves { + /** The bridge's own ROM size limit; a larger file is not a disk image. */ + private const val MAX_BYTES = 16L * 1024 * 1024 + + private fun file(context: Context, sha: String) = + java.io.File(java.io.File(context.filesDir, "fds-saves"), "$sha.fds.sav") + + /** Boot the saved copy if there is one; on any failure keep the pristine disk. */ + fun bootIfSaved(context: Context, ctrl: NesController, sha: String) { + val f = file(context, sha) + if (!f.isFile) return + runCatching { + require(f.length() <= MAX_BYTES) { "saved disk is ${f.length()} bytes" } + ctrl.bootSavedDisk(f.readBytes()) + }.onFailure { android.util.Log.w("RustyNES", "saved FDS disk not used", it) } + } + + /** + * Write the disk if the game changed it, atomically ([android.util.AtomicFile]: + * a crash mid-write keeps the previous file). The dirty flag is cleared only + * after the write lands, so a failure is retried by the next flush. + */ + fun flushIfDirty(context: Context, ctrl: NesController, sha: String) { + if (!ctrl.diskIsDirty()) return + val bytes = ctrl.diskImageBytes() + runCatching { + val f = file(context, sha) + f.parentFile?.mkdirs() + val atomic = android.util.AtomicFile(f) + val out = atomic.startWrite() + try { + out.write(bytes) + atomic.finishWrite(out) + } catch (e: Throwable) { + atomic.failWrite(out) + throw e + } + ctrl.clearDiskDirty() + }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } + } +} + /** * The I/O half of loading a ROM (v2.7.4, frontend audit AND-04): build the * controller, key it by SHA-256, load its battery save and the auto-resume @@ -773,22 +820,17 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri // v2.9.7 "Tandem": the stored FDS BIOS (if the user chose one) rides along, so an // .fds disk boots; a cartridge or NSF ignores it. Without it a disk throws // MobileException.MissingFdsBios, which openRom turns into the BIOS prompt. - var ctrl = NesController.newWithFdsBios(bytes, FdsBios.load(context), 48_000u) + val ctrl = NesController.newWithFdsBios(bytes, FdsBios.load(context), 48_000u) // v2.9.9 (re-audit NF-21): key every per-game store by the core's ROM identity // (the bytes after the iNES header, of the unpacked image), not the file as read. // Before any store is touched, move whatever the pre-v2.9.9 whole-file key held // (once; never over an existing store; see RomKeyMigration). val sha = ctrl.romIdentity() RomKeyMigration.migrate(context.filesDir, sha256Hex(bytes), sha) - val fdsFile = java.io.File(java.io.File(context.filesDir, "fds"), "disk_$sha.fds") - if (fdsFile.exists()) { - runCatching { - val diskBytes = fdsFile.readBytes() - if (diskBytes.size == bytes.size) { - ctrl = NesController.newWithFdsBios(diskBytes, FdsBios.load(context), 48_000u) - } - } - } + // v3.2.0: an FDS disk boots the copy it saved last session, under the pristine + // disk's identity (so `sha` above keys everything, the save file included). + // Before the battery and the auto-resume state below, which are newer. + FdsSaves.bootIfSaved(context, ctrl, sha) // Load the cartridge's `.sav` before its first frame, and before the auto-resume // state below (a save state is newer and restores cartridge RAM too). val (battery, batteryNotice) = attachBattery(context, ctrl, sha) @@ -852,14 +894,7 @@ private fun publishRom( runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } } - if (outgoingSha != null && outgoingCtrl.diskIsDirty()) { - runCatching { - val fdsDir = java.io.File(context.filesDir, "fds") - fdsDir.mkdirs() - java.io.File(fdsDir, "disk_$outgoingSha.fds").writeBytes(outgoingCtrl.diskImageBytes()) - outgoingCtrl.clearDiskDirty() - }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } - } + if (outgoingSha != null) FdsSaves.flushIfDirty(context, outgoingCtrl, outgoingSha) } } // Apply this game's remembered video filter (per-game DB), if any. @@ -1056,10 +1091,18 @@ private class AudioPlayer(sampleRate: Int, private val audioManager: AudioManage * either. Blocks while the ring is full (paces the loop), exactly like [write]. */ fun writeBytes(bytes: ByteArray) { - if (focusMuted || bytes.isEmpty()) return - val written = track.write(bytes, 0, bytes.size, AudioTrack.WRITE_NON_BLOCKING) - if (written < 0) { - // Handle underrun/error logging + // Blocking, deliberately (maintainer, 2026-10-14). A v3.2.0 draft wrote + // `write(byte[], ..., WRITE_NON_BLOCKING)`: that overload is for 8/16-bit + // PCM, not this PCM_FLOAT track, it dropped any partial write, and it + // removed the blocking that clocks this loop to the audio device. + // Non-blocking audio needs its own pacing and is a follow-up for a device. + if (focusMuted) return // another app holds audio focus (AND-03) + if (bytes.isNotEmpty()) { + // The samples are little-endian f32 (from `to_le_bytes`); set the buffer + // order explicitly so the PCM_FLOAT track reads them correctly regardless + // of the JVM's default (BIG_ENDIAN) — Android is LE, so this is identity. + val bb = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN) + track.write(bb, bytes.size, AudioTrack.WRITE_BLOCKING) } } @@ -2789,10 +2832,19 @@ private fun EmulatorScreen( // default-off PGS_ENABLED gate; the increment auto-unlocks at the Console step // target. DISTINCT from RetroAchievements. var turboFrames = 0 + // v3.2.0: keep the screen on while a game is running (a gamepad player + // never touches the screen, so it would otherwise dim and sleep), and let + // it sleep again when paused or closed. Only a change calls through. + var keepScreenOn: Boolean? = null try { while (isActive) { try { val ctrl = emulator.controller + val running = ctrl != null && !emulator.paused + if (running != keepScreenOn) { + keepScreenOn = running + (context as? MainActivity)?.updateScreenWakeLock(running) + } if (ctrl != null && !romLoaded) { romLoaded = true // A fresh controller just appeared: (re-)apply Four Score for the @@ -2905,9 +2957,11 @@ private fun EmulatorScreen( // Hand the raw RGBA frame to the GPU SurfaceView (opt-in path); // no-op when the GPU renderer is off. gpuSurface?.submitFrame(fb) - if (gpuSurface == null) { - packRgbaToArgb(fb, pixels) - } + // Always converted, also on the GPU path: screenshots, clips + // and the external display read this frame. (A v3.2.0 draft + // skipped it when the GPU renderer was on, which left all three + // showing a stale frame; not redone without a device to test it.) + packRgbaToArgb(fb, pixels) } // Hot path: drain audio as raw bytes (no per-sample Float boxing) // and write straight to the PCM_FLOAT track. @@ -2925,10 +2979,8 @@ private fun EmulatorScreen( frame = hdBmp.asImageBitmap() presented = hdBmp } else { - if (gpuSurface == null) { - reuse.setPixels(pixels, 0, NES_WIDTH, 0, 0, NES_WIDTH, NES_HEIGHT) - frame = reuse.asImageBitmap() - } + reuse.setPixels(pixels, 0, NES_WIDTH, 0, 0, NES_WIDTH, NES_HEIGHT) + frame = reuse.asImageBitmap() presented = reuse } // v1.8.8 "Atlas" (Workstream F): publish the gameplay frame for diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index 31d59e13..8c4441cf 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -1,5 +1,3 @@ -#![allow(clippy::needless_borrow)] -#![allow(clippy::items_after_statements)] //! `rustynes-mobile` — the platform-agnostic mobile control surface over //! [`rustynes_core`]. //! @@ -1724,9 +1722,12 @@ impl NesController { /// v2.9.7 — the FDS disk image as it stands, including anything the game /// has written to it, in the headerless `.fds` layout. The host writes it - /// to its own save file and, on the next launch, loads THAT file as the - /// ROM, which is how a disk game's progress persists (FDS games save to - /// the disk; there is no battery RAM). Empty for a cartridge or NSF. + /// to its own save file (keyed by [`Self::rom_identity`]) and, on the next + /// launch, builds from the pristine image and passes the file to + /// [`Self::boot_saved_disk`], which is how a disk game's progress persists + /// (FDS games save to the disk; there is no battery RAM). Loading the saved + /// file AS the ROM instead would change the game's identity (v3.2.0). + /// Empty for a cartridge or NSF. pub fn disk_image_bytes(&self) -> Vec { self.lock().nes.disk_image_bytes() } @@ -1743,6 +1744,60 @@ impl NesController { self.lock().nes.clear_disk_dirty(); } + /// v3.2.0 — boot the host's SAVED copy of the loaded FDS disk (the + /// [`Self::disk_image_bytes`] it wrote last session) in place of the + /// pristine image, KEEPING the pristine image's identity. + /// + /// This is the desktop's `.fds.sav` design (`boot_saved_disk` in the + /// frontend, NF-17). A host that instead loaded the saved file as the ROM + /// would change [`Self::rom_identity`] at the game's first disk save, + /// because the core hashes the bytes it boots: every store keyed on it -- + /// save states, cheats, RA progress, overrides -- would be orphaned, and + /// the save file itself (keyed by identity) would never be found again. + /// + /// Call it right after building the controller from the pristine image, + /// with the file keyed by that controller's `rom_identity()`. The BIOS + /// and sample rate are the controller's own. + /// + /// # Errors + /// [`MobileError::RomLoad`] if the loaded console is not an FDS disk, or + /// the saved image does not parse; the pristine disk stays loaded, so the + /// host can log it and play on (the bad file is not overwritten until + /// the game writes to the disk again). + pub fn boot_saved_disk(&self, saved: Vec) -> Result<(), MobileError> { + check_rom_size(&saved)?; + let not_disk = || MobileError::RomLoad { + reason: "the loaded game is not an FDS disk".into(), + }; + // What the parse needs, read under the lock; the parse runs with it + // released so the render and audio paths are not held up by it. + let (bios, pristine, sample_rate) = { + let g = self.lock(); + if g.nes.disk_side_count() == 0 { + return Err(not_disk()); + } + let bios = g.fds_bios.clone().ok_or_else(not_disk)?; + (bios, *g.nes.rom_sha256(), g.sample_rate) + }; + let mut nes = Nes::from_disk_with_sample_rate(&saved, &bios, sample_rate).map_err(|e| { + MobileError::RomLoad { + reason: format!("saved FDS disk: {e}"), + } + })?; + nes.set_rom_identity(pristine); + let mut g = self.lock(); + // Install it only over the disk it was read for: a different game + // loaded while the lock was released must not be replaced. + if *g.nes.rom_sha256() != pristine { + return Err(MobileError::RomLoad { + reason: "a different game was loaded meanwhile".into(), + }); + } + g.nes = nes; + drop(g); + Ok(()) + } + // --- NSF music files (v2.9.7 "Tandem", plan item 6) ----------------- /// v2.9.7 — the number of songs in the loaded NSF; 0 for a cartridge or @@ -3051,7 +3106,7 @@ fn composite_hd_locked(g: &mut Inner) -> Vec { injected_frame_fault(); let hd_tiles = g.nes.hd_tile_source(); let framebuffer = g.nes.framebuffer(); - comp.composite(&framebuffer, &hd_tiles, &watched, |addr| { + comp.composite(framebuffer, hd_tiles, &watched, |addr| { chr.get((addr & 0x1FFF) as usize).copied().unwrap_or(0) }) .to_vec() @@ -3159,11 +3214,14 @@ fn nat_phase_detail(phase: &NatPhase) -> String { /// input ⇒ byte-identical state) is untouched: the core still cold-boots /// deterministically when the session promotes to `InGame`. fn nondeterministic_seed() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + // OS randomness first (v3.2.0): this seeds the room code and, through the + // session RNG, the STUN / TURN transaction ids, which should not be + // guessable. The time-and-address mix below is the fallback only. let mut buf = [0u8; 8]; if getrandom::getrandom(&mut buf).is_ok() { return u64::from_le_bytes(buf); } - use std::time::{SystemTime, UNIX_EPOCH}; // Truncating the 128-bit nanosecond count to 64 bits is intentional — this is // a PRNG seed, not a timestamp, so the discarded high bits don't matter. #[allow(clippy::cast_possible_truncation)] @@ -4222,6 +4280,50 @@ mod tests { assert_eq!(ctrl.nsf_song_count(), 0, "a disk is not an NSF"); } + /// v3.2.0: a saved disk boots under the PRISTINE disk's identity, so the + /// stores keyed on it (and the save file itself) survive the game's first + /// disk write; a cartridge refuses it and keeps running. + #[test] + fn a_saved_disk_boots_under_the_pristine_identity() { + let ctrl = NesController::new_with_fds_bios( + synthetic_fds_disk(1), + Some(synthetic_fds_bios()), + DEFAULT_SAMPLE_RATE, + ) + .expect("disk loads"); + let pristine = ctrl.rom_identity(); + let mut saved = ctrl.disk_image_bytes(); + let last = saved.len() - 1; + saved[last] ^= 0x5A; // stands in for the game's own write + assert_ne!( + rom_identity_of_file(saved.clone()).unwrap(), + pristine, + "fixture: the saved bytes hash differently" + ); + + ctrl.boot_saved_disk(saved.clone()) + .expect("the saved disk boots"); + assert_eq!( + ctrl.rom_identity(), + pristine, + "identity is the pristine disk's" + ); + assert_eq!( + ctrl.disk_image_bytes(), + saved, + "and the saved bytes are loaded" + ); + ctrl.step_frame(); + + let cart = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load"); + let before = cart.rom_identity(); + assert!( + cart.boot_saved_disk(saved).is_err(), + "a cartridge refuses it" + ); + assert_eq!(cart.rom_identity(), before, "and keeps running"); + } + /// Plan item 6: the BIOS set on a controller persists across `load_rom`, /// so a host that stored it once can swap disks without re-supplying it. #[test] diff --git a/docs/android.md b/docs/android.md index 0863fe18..ea1ca2c6 100644 --- a/docs/android.md +++ b/docs/android.md @@ -286,9 +286,16 @@ The control bar gains **Disk A/B** (next side), **Track n/m** with **<** (NSF), **Screen: left/right** (a cabinet shows one of its two screens at a time) and **Coin** (any Vs. machine). A cabinet's save state is the core's `RVSD` container; netplay, movies and Lua are refused on it with -`MobileException.DualSystem`. The bridge exposes the FDS disk-write accessors -(`diskImageBytes`, `diskIsDirty`, `clearDiskDirty`), but the app does **not yet -write the disk image back**, so a disk game's own save is lost on close. Settings +`MobileException.DualSystem`. **From v3.2.0 a disk game's own save persists**: +when the game has written to its disk (`diskIsDirty`), every session-ending path +(background, ROM switch, close) writes `diskImageBytes` atomically (`AtomicFile`) +to `filesDir/fds-saves/.fds.sav`, the desktop's layout, clearing the +flag only once the write lands. The next load builds from the pristine image and +calls `bootSavedDisk`, which boots the saved copy under the PRISTINE disk's +identity, so the save, the states, RA progress and overrides keep one key (loading +the saved file as the ROM would change it at the first disk write). A bad or +foreign file is logged and the pristine disk plays. Before v3.2.0 the disk image +was not written back and a disk game's save was lost on close. Settings gains **Cancel opposite directions** (neutral SOCD), default on, the desktop's `[input] allow_opposing_directions = false`; off, `socdNeutral(mask, enabled = false)` passes every mask through (`SocdTest` covers both). The device checks are diff --git a/docs/ios.md b/docs/ios.md index 777a656f..0b22544f 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -419,8 +419,12 @@ kept in Application Support (`FdsBiosStore`), and every later disk boots with it The pill menu gains **Flip disk** (FDS), **Next track** (NSF), **Swap screen** (cabinet: the device shows one cabinet screen at a time) and **Insert coin** (any Vs. machine). A cabinet's save state is the core's `RVSD` container; netplay, -movies and Lua are refused on a cabinet. FDS disk writes are exposed by the bridge -(`diskImageBytes` / `diskIsDirty`) but **not yet persisted by the app**. Settings > +movies and Lua are refused on a cabinet. **From v3.2.0 FDS disk writes persist** +(as on Android): a dirty disk is written atomically to +`Application Support/RustyNES/fds-saves/.fds.sav` on the battery-flush +paths, and the next load boots it through `bootSavedDisk`, which keeps the +pristine disk's identity. The Swift half is not yet compiled (no macOS in that +change); the Rust bridge and the Android half are tested. Settings > Controls also gains **Cancel opposite directions** (default on). **All of this Swift compiles** (the iOS workflow's Simulator build, most recently run 37242884341 on `c0b04195`) **and none of it has run on a device**; the checks are diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index bafa50dc..d2f3f132 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -333,22 +333,19 @@ final class AppModel: ObservableObject { audioSession.configure() let data = try await library.romData(for: entry) // v2.9.7: the stored FDS BIOS rides along (a cartridge or NSF ignores it). - var core = try EmulatorCore(romData: data, fdsBios: FdsBiosStore.load()) + let core = try EmulatorCore(romData: data, fdsBios: FdsBiosStore.load()) // v2.9.9 (re-audit NF-21): key this game by the core's identity. An // entry from before v2.9.9 is keyed by the whole file's hash; move it, // then its saves, states, RA progress and overrides, once. The library // goes first: when it cannot move, the old key stays for this session // and nothing else moves, so the stores never split between two keys. let entry = await migrateKey(entry, to: core.romIdentity) - - let fdsUrl = AppModel.fdsDiskUrl(forSha: entry.sha) - if FileManager.default.fileExists(atPath: fdsUrl.path) { - if let diskBytes = try? Data(contentsOf: fdsUrl), diskBytes.count == data.count { - if let reloaded = try? EmulatorCore(romData: diskBytes, fdsBios: FdsBiosStore.load()) { - core = reloaded - } - } - } + // v3.2.0: an FDS disk boots the copy it saved last session, under the + // pristine disk's identity (`bootSavedDisk`), so `entry.sha` keys + // everything, the save file included. On any failure the pristine disk + // stays loaded. Before the battery and the auto-resume state, which are + // newer. + AppModel.bootSavedDiskIfAny(core, sha: entry.sha) core.isMuted = muted // v2.9.2 (AUD-14): pace the gamepad turbo pulse by emulated frames. // Captures the manager (a plain class), not `self`, so the closure @@ -931,9 +928,43 @@ final class AppModel: ObservableObject { /// Compares the save RAM once a second and writes it on a change. private var batteryTimer: Timer? - static func fdsDiskUrl(forSha sha: String) -> URL { + /// v3.2.0 -- an FDS disk's saved image, `Application Support/RustyNES/ + /// fds-saves/.fds.sav` (the desktop's layout). FDS games save to + /// the disk itself, so the whole image is the save. Keyed by the PRISTINE + /// disk's identity and booted through `bootSavedDisk`, which keeps it; the + /// first v3.2.0 draft loaded the saved file as the ROM, which changed the + /// game's identity at its first disk write. + static func fdsSaveUrl(forSha sha: String) -> URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - return base.appendingPathComponent("RustyNES/fds/disk_\\(sha).fds") + return base.appendingPathComponent("RustyNES/fds-saves/\(sha).fds.sav") + } + + /// Boot the saved disk copy for `sha`, if there is one; keep the pristine + /// disk on any failure (logged). + static func bootSavedDiskIfAny(_ core: EmulatorCore, sha: String) { + let url = fdsSaveUrl(forSha: sha) + guard FileManager.default.fileExists(atPath: url.path) else { return } + do { + try core.bootSavedDisk(try Data(contentsOf: url)) + } catch { + NSLog("RustyNES: saved FDS disk not used: \(error)") + } + } + + /// Write the running disk if the game changed it, atomically. The dirty flag + /// is cleared only after the write lands, so a failure is retried by the + /// next flush. + private func flushFdsDiskIfDirty() { + guard let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty else { return } + let url = AppModel.fdsSaveUrl(forSha: sha) + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try core.diskImageBytes().write(to: url, options: .atomic) + core.clearDiskDirty() + } catch { + NSLog("RustyNES: FDS disk save failed: \(error)") + } } /// Bind `core`'s battery RAM to its `.sav` and load it. Returns a message @@ -972,16 +1003,7 @@ final class AppModel: ObservableObject { /// Write the running game's battery save now, and wait for it. private func flushBatteryNow() { - if let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty() { - let url = AppModel.fdsDiskUrl(forSha: sha) - do { - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try core.diskImageBytes().write(to: url, options: .atomic) - core.clearDiskDirty() - } catch { - NSLog("RustyNES: FDS disk save failed: \\(error)") - } - } + flushFdsDiskIfDirty() guard let saver = battery, let core = emulator else { return } saver.flushNow(core.batteryRam()) } @@ -991,16 +1013,9 @@ final class AppModel: ObservableObject { /// is a few KiB, so it runs synchronously within the task rather than handing /// a mutable task id across threads. private func flushBatteryInBackground() { - if let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty() { + if emulator?.diskIsDirty == true { let task = BackgroundTask(name: "RustyNES FDS save") - let url = AppModel.fdsDiskUrl(forSha: sha) - do { - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try core.diskImageBytes().write(to: url, options: .atomic) - core.clearDiskDirty() - } catch { - NSLog("RustyNES: FDS disk save failed: \\(error)") - } + flushFdsDiskIfDirty() task.end() } guard let saver = battery, let core = emulator else { return } diff --git a/ios/RustyNES/EmulatorCore.swift b/ios/RustyNES/EmulatorCore.swift index 5f5fa2a2..bfae2a30 100644 --- a/ios/RustyNES/EmulatorCore.swift +++ b/ios/RustyNES/EmulatorCore.swift @@ -500,6 +500,17 @@ final class EmulatorCore { controller.setDiskSide(side: UInt32(next)) } + /// v3.2.0 -- the FDS disk save (see `AppModel.fdsSaveUrl`). The game writes + /// to the disk itself; the host saves the whole image when it is dirty and + /// boots it next session through `bootSavedDisk`, which keeps the pristine + /// disk's identity. All four are no-ops / empty for a cartridge or NSF. + var diskIsDirty: Bool { controller.diskIsDirty() } + func diskImageBytes() -> Data { controller.diskImageBytes() } + func clearDiskDirty() { controller.clearDiskDirty() } + /// - Throws: `MobileError` if this is not a disk or `saved` does not parse; + /// the pristine disk then stays loaded. + func bootSavedDisk(_ saved: Data) throws { try controller.bootSavedDisk(saved: saved) } + /// NSF songs (0 for a cartridge / disk) and the playing one (0-based). var nsfSongCount: Int { Int(controller.nsfSongCount()) } var nsfCurrentSong: Int { Int(controller.nsfCurrentSong()) } diff --git a/ios/RustyNES/RomIdentity.swift b/ios/RustyNES/RomIdentity.swift index 65ab622b..fa5d9d68 100644 --- a/ios/RustyNES/RomIdentity.swift +++ b/ios/RustyNES/RomIdentity.swift @@ -122,10 +122,6 @@ enum RomKeyMigration { root.appendingPathComponent("battery/\(legacy).sav"), root.appendingPathComponent("battery/\(identity).sav") )) - outcome.add(moveFile( - root.appendingPathComponent("fds/disk_\(legacy).fds"), - root.appendingPathComponent("fds/disk_\(identity).fds") - )) outcome.add(moveDir( root.appendingPathComponent("states/\(legacy)", isDirectory: true), root.appendingPathComponent("states/\(identity)", isDirectory: true) From 0a6a08d26f65fc7b18a71cfab8f30db8e0f09474 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:17:19 -0400 Subject: [PATCH 050/104] fix(cheevos): pin the import's token redaction; clarify the callback SAFETY Two changes to rustynes-cheevos in the codex_sec import (bb66f0af), which no audit agent had reviewed. Both are kept; this adds the missing test and fixes a weakened SAFETY note. - `RaUser`'s derived `Debug` is replaced by one that prints the login token as "". The token logs a user in without a password, and any `{:?}` of a user (or of a struct holding one) used to print it. New test `a_users_debug_output_redacts_the_token` (the token absent, the marker and the other fields present); mutation -- printing the field -- fails it with the token in the message. - `rc_client_server_callback_t` becomes `Option` (FFI- safe: the null-pointer optimisation makes it the same C function pointer), so a NULL completion callback from rcheevos is carried as 0 and skipped in `poll_completions` instead of called. The import replaced the original SAFETY reasoning with "Transmuting the usize back to the function pointer is safe", a claim rather than a reason; the note now says why (a non-zero value is the bits of the exact non-null pointer the trampoline received) and the outer comment explains the 0 case. Verified: rustynes-cheevos 10 tests (with the vendored rcheevos built); clippy -D warnings for the crate and for rustynes-frontend with `retroachievements`; fmt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-cheevos/src/client.rs | 28 +++++++++++++++++++++++++++ crates/rustynes-cheevos/src/http.rs | 11 +++++++---- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/rustynes-cheevos/src/client.rs b/crates/rustynes-cheevos/src/client.rs index 72a12b2e..a1dc5edf 100644 --- a/crates/rustynes-cheevos/src/client.rs +++ b/crates/rustynes-cheevos/src/client.rs @@ -757,3 +757,31 @@ impl Drop for RaClient { // `transport` is dropped after this, joining the worker thread. } } + +#[cfg(test)] +mod tests { + use super::RaUser; + + /// v3.2.0: the login token never reaches a `Debug` rendering, so logging a + /// user (or a struct holding one) cannot leak the credential. + #[test] + fn a_users_debug_output_redacts_the_token() { + let user = RaUser { + display_name: "Shown".into(), + username: "acct".into(), + token: "s3cr3t-token-value".into(), + score: 10, + score_softcore: 20, + }; + let shown = format!("{user:?}"); + assert!( + !shown.contains("s3cr3t-token-value"), + "token leaked: {shown}" + ); + assert!(shown.contains("")); + assert!( + shown.contains("acct") && shown.contains("Shown"), + "the rest is kept" + ); + } +} diff --git a/crates/rustynes-cheevos/src/http.rs b/crates/rustynes-cheevos/src/http.rs index 33cd7c36..54030d81 100644 --- a/crates/rustynes-cheevos/src/http.rs +++ b/crates/rustynes-cheevos/src/http.rs @@ -96,13 +96,16 @@ impl HttpTransport { while let Ok(done) = self.completion_rx.try_recv() { // Rebuild the C callback pointer + data from the carried bits. let cb: ffi::rc_client_server_callback_t = { - // SAFETY: `done.callback` is the exact pointer rcheevos handed - // to the trampoline; transmuting the bits back is sound and it - // is invoked here on the main thread. + // rcheevos may pass a NULL completion callback; the trampoline + // carried it as 0 (v3.2.0, the callback type is an `Option`), + // and it is skipped below instead of called. if done.callback == 0 { None } else { - // SAFETY: Transmuting the usize back to the function pointer is safe. + // SAFETY: a non-zero `done.callback` is the bits of the exact + // non-null `extern "C" fn` rcheevos handed the trampoline + // (`server_call_trampoline`), so transmuting them back yields + // that pointer; it is invoked here, on the main thread. unsafe { std::mem::transmute::( done.callback, From ac78f415d8a86973f9bcdd3b632ef4a96be9c9fb Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:18:32 -0400 Subject: [PATCH 051/104] fix(cosim): keep the unaligned-output copy; drop the one catch_unwind Two changes the codex_sec import (bb66f0af) made to the co-simulation FFI crate (outside the workspace, so neither the workspace clippy nor its tests saw them; with them, `cargo clippy` in the crate failed). - `rn_run_frame` alone of the crate's 15 `extern "C"` exports was wrapped in a `catch_unwind` macro returning 0 on panic, and the macro was placed between the function's doc comment and its signature, so the doc -- including its `# Safety` section -- documented the macro instead. REVERTED: since Rust 1.81 a panic escaping an `extern "C"` function aborts, which is the right failure for a test oracle; catching it here turned a core panic into a cycle count of 0 that the sibling's harness would compare as data. A comment at the site says why. - `rn_index_framebuffer` copied `u16`s into the caller's buffer with `copy_nonoverlapping::`, which requires 2-byte alignment that a C caller does not have to provide. KEPT: an unaligned buffer is now filled by a byte copy of the same bytes. Its two new `unsafe` blocks had no SAFETY notes of their own; they do now, and the byte count is `size_of_val(fb)` (clippy's manual_slice_size_calculation, which is what failed the crate's lint). Verified (in crates/rustynes-cosim): fmt, clippy --all-targets -D warnings, 54 tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-cosim/src/lib.rs | 34 +++++++++++++++----------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs index a770fd41..c7d40805 100644 --- a/crates/rustynes-cosim/src/lib.rs +++ b/crates/rustynes-cosim/src/lib.rs @@ -695,22 +695,15 @@ macro_rules! oracle { /// # Safety /// /// `handle` must be a live pointer from [`rn_open`]. -macro_rules! catch_ffi { - ($default:expr, $body:expr) => { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { - Ok(res) => res, - Err(_) => $default, - } - }; -} - #[unsafe(no_mangle)] pub unsafe extern "C" fn rn_run_frame(handle: *mut c_void) -> c_ulonglong { - catch_ffi!(0, { - let o = oracle!(handle, 0); - o.nes.run_frame(); - o.nes.bus().cycle() - }) + // No `catch_unwind` here, deliberately (v3.2.0 review). A panic escaping an + // `extern "C"` function aborts the process, which is what a test oracle + // should do: a draft caught it in this one export and returned 0, which the + // co-simulation harness would have read as a cycle count. + let o = oracle!(handle, 0); + o.nes.run_frame(); + o.nes.bus().cycle() } /// Execute one instruction. Returns the cycles it took. @@ -760,17 +753,22 @@ pub unsafe extern "C" fn rn_index_framebuffer( if out.is_null() || cap < fb.len() { return 0; } - // SAFETY: `out` is non-null and the caller guarantees `cap` writable u16s, - // and `cap >= fb.len()` was just checked. The regions cannot overlap: `fb` - // borrows emulator-owned memory, `out` is caller-owned. + // `out` is non-null and the caller guarantees `cap` writable u16s, and + // `cap >= fb.len()` was just checked. The regions cannot overlap: `fb` + // borrows emulator-owned memory, `out` is caller-owned. A C caller may pass + // a buffer that is not 2-byte aligned, which a `u16` copy may not touch + // (v3.2.0), so that case copies the same bytes as bytes. if (out as usize) & (core::mem::align_of::() - 1) == 0 { + // SAFETY: as above, and `out` is aligned for `u16`. unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; } else { + // SAFETY: as above; a `u8` copy has no alignment requirement, and + // `size_of_val(fb)` bytes is exactly the `fb.len()` u16s checked to fit. unsafe { core::ptr::copy_nonoverlapping( fb.as_ptr().cast::(), out.cast::(), - fb.len() * core::mem::size_of::(), + core::mem::size_of_val(fb), ); } } From 7857f27321dce5b10980c1770f20affe05d7d943 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:19:38 -0400 Subject: [PATCH 052/104] docs(shaders): keep the import's WGSL changes; fix a stale reference The codex_sec import (bb66f0af) changed five WGSL sources in rustynes-gfx-shaders, which no audit agent had reviewed. Both changes are kept after reading them; this commit only fixes a comment one left stale. - `textureSample(...)` -> `textureSampleLevel(..., 0.0)` in crt_guest, crt_royale, megatron and the two shaders embedded in lib.rs. The NES source texture has a single mip level, so sampling level 0 explicitly returns the same texel through the same filter; what changes is that it no longer needs screen-space derivatives, so it is valid in non-uniform control flow (these fragment shaders return early on the letterbox border, which WGSL's uniformity analysis flags for `textureSample`) and skips the implicit LOD computation. - signal_decode.wgsl: `composite_at` did a clamped `textureLoad` of the packed index texel for every sub-sample; the decode loop walks ~8 sub-samples per source column, so the import loads it once per column (`last_col` cache) and passes it to `composite_at_packed`. Same value per sub-sample, so the same result. `dim` is `fs_main`'s own, and nothing else called `composite_at`. A comment still named `composite_at`; fixed. Verification: every touched source is parsed and validated by naga in the frontend's tests -- `crt::tests::crt_stack_shaders_parse_and_validate` (guest, royale, megatron, signal decode) and the `shader_parses_and_ validates` tests for the lib.rs shaders -- 39 shader-related tests pass; rustynes-gfx-shaders' own 3 tests pass. Not done: a rendered pixel comparison against the pre-import shaders (no headless GPU harness exists for these passes); the equivalence above is by reading. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-gfx-shaders/src/signal_decode.wgsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl index 0def43e6..22187b3d 100644 --- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl +++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl @@ -147,7 +147,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let row = i32(floor(suv.y * rows)); // Per-line phase offset gives the 3-phase dot crawl (params.x is the frame // videoPhase; the row parity contributes the per-line shift). Wrapped - // non-negative for the same edge-consistency reason as `composite_at`. + // non-negative for the same edge-consistency reason as `composite_at_packed`'s caller. let line_phase = pmod(i32(round(u.params.x)) + row * 8, 12); // Centre position in absolute sub-samples: 8 per pixel. From 7086ae36f83279597734467549d5164847befef6 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:22:11 -0400 Subject: [PATCH 053/104] fix(ios): make the zero-copy render shim compile; restore its SAFETY notes The codex_sec import (bb66f0af) made the iOS shim upload each frame straight from the FFI buffer: `rustynes_ios_gfx_render` passes the slice to `MetalGfx::render(fb)`, which hands it to `write_texture` (which stages it itself), instead of first copying it into a reused `frame_buf`. That saves one 240 KiB memcpy per frame and is kept. As imported it could not compile: `ffi.rs` compared against `NES_W * NES_H * 4`, but those are private constants of `gfx_metal.rs` and were not imported -- invisible to every Linux gate, because the shim is iOS-only. It also deleted the SAFETY notes on both `unsafe` blocks it kept. - `gfx_metal::FRAME_LEN` (pub(crate)) is the one frame length; `ffi.rs` checks against it and `render` debug-asserts it. - Both SAFETY notes are back, the slice's one rewritten for the new lifetime (it lives only for the call; `write_texture` copies before returning). - `render`'s doc says it uploads the caller's buffer. Also restored, in rustynes-core `SystemBus::debug_peek_ppu`: the import replaced the save / ppu_read / restore bracket with `ppu_peek` and deleted the comment recording why it matters (the pattern viewer flipping Punch-Out!!'s CHR latch until v3.1.0). A short version is back, pointing at the `ppu_peek` contract and the sweep that checks it. Verification, and its limit: `cargo check -p rustynes-ios --target aarch64-apple-ios` cannot run on this host -- `ring`'s build script needs `xcrun` (Xcode) -- so the shim is checked by reading: `FRAME_LEN` is reachable as `crate::gfx_metal::FRAME_LEN` and no `frame_buf` reference remains. The `ios` CI job (manual) is the first compile. rustynes-core fmt and clippy -D warnings are clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-core/src/bus.rs | 7 +++++++ crates/rustynes-ios/src/ffi.rs | 8 +++++++- crates/rustynes-ios/src/gfx_metal.rs | 10 ++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 64e8e28b..721127c2 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -1780,6 +1780,13 @@ impl SystemBus { pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 { let addr = addr & 0x3FFF; match addr { + // Side-effect-free on every board: a debugger panel or an HD-pack + // tile hash must never change the game (until v3.1.0 opening the + // pattern viewer on Punch-Out!! flipped its CHR latch). Since v3.2.0 + // that is `Mapper::ppu_peek`'s contract -- exactly the byte + // `ppu_read` would return, with no state change -- checked for every + // board by `ppu_peek_matches_a_side_effect_free_ppu_read`, instead of + // a save / `ppu_read` / restore around impure boards here. 0x0000..=0x1FFF => self.mapper.ppu_peek(addr), 0x2000..=0x3EFF => { let addr = if addr >= 0x3000 && !self.mapper.nametable_unfolded() { diff --git a/crates/rustynes-ios/src/ffi.rs b/crates/rustynes-ios/src/ffi.rs index da09b87a..22d31fcc 100644 --- a/crates/rustynes-ios/src/ffi.rs +++ b/crates/rustynes-ios/src/ffi.rs @@ -95,10 +95,16 @@ pub unsafe extern "C" fn rustynes_ios_gfx_render(handle: *mut MetalGfx, fb: *con if handle.is_null() || fb.is_null() { return; } + // SAFETY: live handle (see above). let gfx = unsafe { &mut *handle }; - if len != (NES_W * NES_H * 4) as usize { + if len != crate::gfx_metal::FRAME_LEN { return; } + // SAFETY: `fb` points to `len` readable bytes (caller contract), and + // `len` is exactly one frame. The slice only lives for this call: + // `render` hands it to `write_texture`, which copies it into wgpu's + // staging before returning (v3.2.0; this used to copy it into a reused + // `frame_buf` first, one 240 KiB memcpy per frame for nothing). let src = unsafe { core::slice::from_raw_parts(fb, len) }; gfx.render(src); }) diff --git a/crates/rustynes-ios/src/gfx_metal.rs b/crates/rustynes-ios/src/gfx_metal.rs index 4070e266..7f9bc1f6 100644 --- a/crates/rustynes-ios/src/gfx_metal.rs +++ b/crates/rustynes-ios/src/gfx_metal.rs @@ -29,6 +29,9 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle, UiKitDisplayHandle, U /// NES framebuffer dimensions (must match `rustynes_mobile::{FRAME_WIDTH,_HEIGHT}`). const NES_W: u32 = 256; const NES_H: u32 = 240; +/// One RGBA8 frame, in bytes: what `rustynes_ios_gfx_render` accepts and +/// `MetalGfx::render` uploads. +pub(crate) const FRAME_LEN: usize = (NES_W * NES_H * 4) as usize; /// NES pixel aspect ratio (8:7) → the displayed image is wider than 256:240. const PAR_W: f32 = NES_W as f32 * 8.0 / 7.0; const IMG_ASPECT: f32 = PAR_W / NES_H as f32; @@ -556,9 +559,12 @@ impl MetalGfx { self.write_uniforms(); } - /// Upload the staged 256×240 RGBA frame and present it. Tolerates a transient - /// `Lost`/`Outdated` surface by reconfiguring and skipping the frame. + /// Upload one 256×240 RGBA frame (`fb`, [`FRAME_LEN`] bytes) and present it. + /// Tolerates a transient `Lost`/`Outdated` surface by reconfiguring and + /// skipping the frame. `fb` is uploaded straight from the caller's buffer + /// (v3.2.0; a reused staging copy before). pub fn render(&mut self, fb: &[u8]) { + debug_assert_eq!(fb.len(), FRAME_LEN, "one RGBA frame"); // Re-write the active filter's uniform each frame so the normal path is // self-correcting after an HD-pack frame wrote a neutral (plain-blit) // uniform into the shared buffer (v1.9.5). One 64-byte write — negligible. From 993dbc70b8c1df2f299005875e1acea4b7ef9a16 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:23:46 -0400 Subject: [PATCH 054/104] chore: restore the lost 2026-10-09 salvage record; ignore salvaged_tmp/ The 2026-10-09 /tmp salvage (883 hand-written scratch files moved into the gitignored salvaged/tmp-scratch/f63c2db0/, and the mislabelled Boogerman dump into salvaged/mislabelled-dumps/) was recorded in docs/SALVAGE_MANIFEST.md but never committed, and the entry was lost when the working tree was reset to adopt the agy/Codex branch (Step 0 of the v3.2.0 consolidation). The salvage itself happened -- both destinations exist -- so the record is restored verbatim from the session transcript (the one Edit that wrote it, 2026-10-09 12:40:07 UTC; no later edit changed it). It already describes the move the maintainer chose over a copy. `.gitignore` gains `/salvaged_tmp/`: the agy branch committed a `salvaged_tmp/` tree holding session transcripts, a /tmp listing and the author's email address; it is kept privately under the gitignored `salvaged/` and must not re-enter the tree under its own name. Verified: markdownlint on the manifest; `git check-ignore` matches `salvaged_tmp/` against the new rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .gitignore | 4 ++++ docs/SALVAGE_MANIFEST.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/.gitignore b/.gitignore index 3385cc02..e4662ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -352,6 +352,10 @@ WARP.md # Local scratch / salvage working dir (data/docs/logs pulled from /tmp triage); not # part of the repo. /salvaged/ +# v3.2.0: an AI agent's /tmp-salvage scratch from the agy/Codex remediation +# branch (session transcripts, a /tmp listing, the author's address). Kept in the +# gitignored salvaged/ above; it must never be committed under this name either. +/salvaged_tmp/ # NOTE: BestEffort boot-smoke ROMs (homebrew/unlicensed/multicart) are no longer # a separate tree — they live under the already-ignored tests/roms/external/ diff --git a/docs/SALVAGE_MANIFEST.md b/docs/SALVAGE_MANIFEST.md index 863d6eb9..93fc45c9 100644 --- a/docs/SALVAGE_MANIFEST.md +++ b/docs/SALVAGE_MANIFEST.md @@ -203,3 +203,33 @@ it. The facts below are from the dry-run and execution output, not memory. | `/tmp/test_fix1.rs`, workspace-root task outputs, the holy-mapperel `hm02` one-line Python fix | small | Not chosen by the maintainer. `hm02` is a scratch clone whose only change is `collections.Sequence` becoming `collections.abc.Sequence`. | | `/tmp/agy_env.txt` | 6 KB | An environment dump with lines matching token / key patterns. Never offered for the repository tree. | | Foreign agent scratch | 545 matches, MARM-Stack | Another project's. | + +## 2026-10-09 — tmp-salvage (move, curated by hand: session f63c2db0, v3.0.1-v3.2.0) + +Deferred until the four v3.2.0 agents stopped (they read their briefs from this +scratch). The tool's own pass could not narrow the claim: with the session's +scratch as its root every path contains the project token, so all 40,580 files +matched. The list was therefore built by hand: hand-written file types (`.txt`, +`.md`, `.py`, `.sh`, `.rs`, `.toml`, `.log`, `.tsv`, `.json`, `.diff`, +`.patch`, `.sv`, `.cpp`, `.yml`, `.csv`) under 2 MiB, outside the regenerable +trees listed below. Moved with a SHA-256 check before each move: 883 files, +15,396,645 bytes, 0 identical-skipped, 0 conflicts, 0 errors. + +### Claimed (883 files, 1 ROM) + +| Source | Destination | Notes | +|---|---|---| +| `scratchpad/{v301,v310,v320,misc,...}` hand-written files | `salvaged/tmp-scratch/f63c2db0/` | Commit-message drafts (`msg-*.txt`), the v3.2.0 agent briefs (`v320/agent-brief.md`, `v320/sibling-brief.md`), probe crates' `src/` and `Cargo.toml` (`tierprobe`, `probe185`), the MiSTer mapper generators (`v320/mappers/mkmapper*.py`), gate / bless / ladder logs, PR review-reply drafts (`v310/replies*`), and the v3.0.1 seed-sweep and ledger JSON (`v301/sweep`). Resuming a stopped v3.2.0 agent needs its brief from here now, not from `/tmp`. | +| `scratchpad/v320/removed-dumps/Boogerman (Sugar Softec) (Unl) [!].zip` | `salvaged/mislabelled-dumps/m215-header_is-m114s1_Boogerman (Sugar Softec) (Unl) [!].zip` | The GoodNES copy headered as mapper 215, moved out of the corpus in v3.2.0 (it draws grey garbage as 215; the 114 submapper 1 copy with a NES 2.0 header boots). A commercial ROM: `salvaged/` is gitignored, and it must stay out of the tree. | + +### Found and deliberately NOT claimed + +| Group | Size | Why not | +|---|---|---| +| Clones | ~1.6 GB | `oracle-clone`, `oracle-clone-merge`, `oracle-pin`, `cleanclone`, `sprites/oracle`, `acoin/AccuracyCoin`, `wiki/Wiki_MiSTer.wiki`, `v310/lrs`, `v310/acsrc`: git repositories, recreatable from their remotes. | +| Regenerable bulk | ~2.5 GB | The `mkvenv` Python venv, cargo `target/` trees (`tgt-clean`, the probe crates'), the 1.5 GB `oddskip` perf run and the built `mkdocs-site`. | +| Copies of tracked files | ~4 MB | `v310/preedit`, `v310/split`, `v310/oam`, `v301/pinsnap` (snapshots of `CHANGELOG.md`, `app.rs` and mutant `.rs` files) and the `v310/slice-*.patch` review slices: all in git history. | +| NESdev text extracts, the TriCNES trace | ~1 MB | `misc/wiki` re-renders the vendored `nesdev_wiki/` pages; `v310/dma/tri_xc.txt` is a trace the harness regenerates. | +| Extracted commercial ROMs and frames rendered from them | small | `v320/g2`, `v320/roms290`, PNG dumps: derived from commercial dumps, never for the tree. | +| 60 loose `/tmp` patches, diffs and parse scripts | ~1.5 MB | Review-bot artifacts of RustyNES PRs (Oct 5-8): regenerable from git, the `.md` drafts already posted. Left by the maintainer's choice. | +| Foreign agent scratch | 15,425 matches in 39 groups | Other projects' (VeridianOS, MARM-Stack, KDE dry runs and more). Left by the maintainer's choice. | From b3c204eb402c3fdbf44707004a796a93939a87c3 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:27:33 -0400 Subject: [PATCH 055/104] docs(audits): file the agy Round 2 report and its disposition ledger The v3.2.0 consolidation adopted an AI-agent branch (`agy/perf_impr`) whose changes were driven by a 61-finding "Round 2" audit, `docs/Round2-Audit.md` on that branch. The branch is not merged, so the report would otherwise exist only in a private salvage folder. It is filed here beside the five earlier audits: - `docs/audits/v3.2.0-round2-audit-report.md` -- the report, byte-for-byte below an HTML-comment header (verified with `diff` against the salvaged original; the whitespace hooks changed nothing). The header records who wrote it, where it came from, and -- per the guardrails' "do not self-certify" rule -- that its provenance and certification claims ("Clean-Room Black-Box Oracle Architecture ... Firewall Verified", section 2.3's "No reference emulator source code ... was opened", "100% bit-accurate", "certified by Audit Report Synthesis Worker") are an AI's statements about its own process, not project fact. Two of them were tested and are false: CORE-R2-02 broke open bus on mappers 228 and 235, and MAP-R2-07's MMC5 detector stopped the IRQ firing. It names the private MiSTer sibling and its `rtl/` paths (no links), as the earlier RTL audits here already do. - `docs/audits/v3.2.0-round2-audit-disposition.md` -- one row per finding (61, checked by script against the report's own headings), each with a verdict and the commit that settled it: KEPT 20, FIXED 14, CHANGED, UNVERIFIED 1 (the iOS shim, not buildable here), REVERTED 4, REJECTED 4, UNTRIAGED 11 (never applied by the branch), DEFERRED 7 (the RTL items, for the sibling integration). Three earlier-round changes in the same import (N163, `ppu_peek`, libretro slim states, mobile FDS saves) are recorded too. Its calibration: of 43 applied changes the suites caught two (MMC5 via the commercial oracle, FDS via the coverage sweep); reading found the rest. - `docs/audits/README.md` gains a section for the report, its calibration, and how this ledger's change-centred verdicts map onto the README's standard set. Not committed, by design: the agent's `Claude_Handoff.md` and `salvaged_tmp/` (session transcripts, a /tmp listing, the author's address) stay in the gitignored salvage folder; the one useful fact from the handoff (CORE-R2-01 was tried and reverted for breaking determinism) is in the ledger. Verified: markdownlint on all three; the ledger's IDs equal the report's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- docs/audits/README.md | 28 + .../audits/v3.2.0-round2-audit-disposition.md | 162 + docs/audits/v3.2.0-round2-audit-report.md | 2721 +++++++++++++++++ 3 files changed, 2911 insertions(+) create mode 100644 docs/audits/v3.2.0-round2-audit-disposition.md create mode 100644 docs/audits/v3.2.0-round2-audit-report.md diff --git a/docs/audits/README.md b/docs/audits/README.md index 8d3db3ae..0dc3d6c3 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -65,6 +65,34 @@ less well. The pattern held for the fifth time: - **Its best result was indirect.** AUD-02's sweep found the same save-state RAM gap on nine boards the report never named (row AUD-02b). +## The v3.2.0 Round 2 audit + +A sixth report, found on 2026-10-13 with an unpushed AI-agent branch +(`agy/perf_impr`, Antigravity with Codex workers): 61 findings across the core, +mappers, APU, PPU, netplay, frontend, mobile, libretro, security and the MiSTer +RTL, kept as supplied in +[`v3.2.0-round2-audit-report.md`](v3.2.0-round2-audit-report.md) under a header +that marks its self-certification ("clean-room", "certified", "100% +bit-accurate") as unverified. Unlike the others it arrived with its fixes +already applied, so its ledger, +[`v3.2.0-round2-audit-disposition.md`](v3.2.0-round2-audit-disposition.md), +records a verdict on each APPLIED CHANGE: KEPT (the change landed as written -- +FIXED in the vocabulary below), FIXED (corrected in a named commit with a +failing test), REVERTED and REJECTED (closer to REJECTED FIX and REFUTED), and +the standard UNTRIAGED, DEFERRED and CHANGED, UNVERIFIED. The work is the +v3.2.0 consolidation's. + +**Calibration, 2026-10-14.** The Rust findings were usually real and the +fixes often wrong in detail -- the pattern of every earlier report. Of the 43 +applied changes the test suites caught two (the MMC5 IRQ through the +commercial-dump oracle, the FDS audio rounding through the dump coverage +sweep); reading each change against the code it replaced found the rest, +among them a 47% audio regression, open bus broken on two mappers, MMC3 bank +masking, a netplay message that was sent and never decoded, two threads +stepping one core, a heap cap that never fires, and FDS saves that changed a +game's identity. Two of its self-certifying claims were tested and are false. +The seven RTL findings are not yet re-derived. + ## What these reports are They were **written by an AI agent** (the Antigravity CLI, `agy`) and committed in diff --git a/docs/audits/v3.2.0-round2-audit-disposition.md b/docs/audits/v3.2.0-round2-audit-disposition.md new file mode 100644 index 00000000..6204cd72 --- /dev/null +++ b/docs/audits/v3.2.0-round2-audit-disposition.md @@ -0,0 +1,162 @@ +# v3.2.0 Round 2 audit -- disposition + +What was done with each of the 61 findings in +[`v3.2.0-round2-audit-report.md`](v3.2.0-round2-audit-report.md). That report +came with an unpushed AI-agent branch (`agy/perf_impr`, also `codex_sec`) that +had already applied most of them. The branch was not merged: its code was +imported as one commit (`bb66f0af`) on the v3.2.0 consolidation branch, and +every change in it was then read against the code it replaced. The commits +cited below are on that branch. + +**Verdicts.** KEPT: the import's change is right as written (or needed only +documentation). FIXED: the change was right in intent and wrong in detail; +the cited commit corrects it, with a test that fails without the correction. +REVERTED: the change made things worse, and the pre-import code is back. +REJECTED: the finding's premise does not hold. CHANGED, UNVERIFIED: as in +the [README](README.md#verdicts) -- changed, but no gate on this machine can +build it. UNTRIAGED: the import never +applied it; the finding has not been checked against the code and must be +re-derived before anyone acts on it. DEFERRED: real, or plausibly real, and +left for a later release with a reason. + +**Calibration.** The pattern of the earlier audits held for the sixth time: +the Rust findings were usually real, and the fixes often wrong in detail. Of +the 43 applied changes, the test suites caught two: the commercial-dump +oracle caught the MMC5 IRQ (MAP-R2-07) and the dump coverage sweep the FDS +audio rounding (MAP-R2-03); the full run (3,631 tests, with test ROMs and +commercial dumps) passed everything else. Every other defect below was found +by reading the change against the code it replaced, and each now has a test +that fails without the correction. The report's +self-certification ("clean-room", "certified", "100% bit-accurate") is +recorded at the top of the report as unverified; two of those claims were +tested and are false (CORE-R2-02, MAP-R2-07). + +## Audio (APU) + +| ID | Verdict | What was done | +| --- | --- | --- | +| APU-R2-01 | KEPT | `CacheAligned` padding on the audio ring's head and tail (`audio.rs`). | +| APU-R2-02 | FIXED | `add_sample_bounded` kept, reached through one call site with `BlipBuf::sanitize` (`fcd22cf9`). | +| APU-R2-03 | REVERTED | The reshaped ring write made `apu_tick_active_frame_with_external` 46-48% slower in two A/B runs; the two-run form is back, with the measurement at the site (`fcd22cf9`, `docs/performance.md` "v3.2.0 APU"). | +| APU-R2-04 | KEPT | Filter split; mangled doc comments rewritten (`fcd22cf9`). With APU-R2-02: active frame -1.9% / -2.7%, external -2.9% / -6.1%, two runs. | +| APU-R2-05 | FIXED | `*= 0.25` tied to the comb count as `COMB_GAIN` (`e222720c`). | +| APU-R2-06 | KEPT | `AtomicU32` ring slots. | +| APU-R2-07 | FIXED | The pulse comment is now accurate and cites the wiki (the code always incremented) (`3fb6bc58`). | +| APU-R2-08 | FIXED | Noise short mode is "93 or 31 steps" (nesdev "APU Noise"), not "93-step"; the mode-0 "15-bit period" also corrected (`3fb6bc58`). | + +## Picture (PPU, shaders, presentation) + +| ID | Verdict | What was done | +| --- | --- | --- | +| PPU-R2-01 | KEPT | `cached_emph_offset` re-derived on restore; schema-audit entry added. Byte-identical across the full suite. | +| PPU-R2-02 | KEPT | `textureSampleLevel(.., 0.0)`: the source texture has one mip level (`7857f273`). | +| PPU-R2-03 | KEPT | `signal_decode.wgsl` loads the texel once per column; a stale comment fixed (`7857f273`). Validated by naga in the frontend tests; no pixel comparison exists. | +| PPU-R2-04 | KEPT | `PresentBuffer::take_into` swaps instead of copying; the doc now says why that is safe (`e222720c`). | +| PPU-R2-05 | KEPT | `emit_pixel` hoist. Byte-identical across the full suite. | +| PPU-R2-06 | KEPT | Secondary OAM written on even dots only. Not observable: the buffer is read only on dots 257-320, after the clear, and by the save state. Matches nesdev's "odd cycles read, even cycles write". Listed as a revert in the consolidation plan; on the evidence, kept. | +| PPU-R2-07 | KEPT | Documentation. | + +## Core + +| ID | Verdict | What was done | +| --- | --- | --- | +| CORE-R2-01 | UNTRIAGED | Not applied. The agy branch tried it and reverted it after it broke determinism (its own handoff note). Needs a red test before any change. | +| CORE-R2-02 | FIXED | The "100% bit-accurate" shortcut read ROM where mappers 228 and 235 float the bus. Replaced by a per-board `may_unmap_prg_rom` flag, a debug-build contract check and a sweep over every mapper (`5a38c02b`). | +| CORE-R2-03 | UNTRIAGED | Not applied. | +| CORE-R2-04 | UNTRIAGED | Not applied. | +| CORE-R2-05 | UNTRIAGED | Not applied. | +| CORE-R2-06 | UNTRIAGED | Not applied. | +| CORE-R2-07 | UNTRIAGED | Not applied. | + +## Mappers and the game database + +| ID | Verdict | What was done | +| --- | --- | --- | +| MAP-R2-01 | KEPT | Table CRC-32 and `debug_info() ==`; the dropped Seicross comment restored; the `same_board` and sub-0-correction changes documented and pinned (`85881197`). | +| MAP-R2-02 | FIXED | Cached bank offsets kept. GxROM did not recompute them on `load_state` (`37102eb5`); MMC3 reduced bank numbers modulo instead of masking them (`c7126096`). UxROM, CNROM and AxROM were right. | +| MAP-R2-03 | FIXED | FME-7's unrolled mix and MMC5's pulse reorder kept; the FDS output formula REVERTED -- it moved every FDS game's audio (`97fbc115`). | +| MAP-R2-04 | KEPT | VRC2 1-bit mirroring and 23 / 25 submapper 3 as VRC2 (maintainer: adopt), cited from nesdev and pinned (`ea8752cb`). | +| MAP-R2-05 | FIXED | The flash boards set their dirty flag only on a program write; a state load, a power-on clear and `save_data_mut` now set it too (`23da71ad`). | +| MAP-R2-06 | FIXED | Fire Hawk detection limited to submapper 0 (maintainer), cited and pinned (`ea8752cb`). | +| MAP-R2-07 | REVERTED | The nametable-snoop detector lived in `ppu_read`, which the bus never calls for nametables: the MMC5 IRQ stopped firing in real play (`9c8539ae`). | + +The same import carried three earlier-round changes, triaged the same way: +the N163 output cache mixed inactive channel slots, REVERTED (`4410d325`); +`ppu_peek`, made required and exact, with mapper 209 FIXED (`298a2cb8`); and +the libretro slim save states, KEPT after tests (`fa425caa`). + +## Netplay + +| ID | Verdict | What was done | +| --- | --- | --- | +| NET-R2-01 | UNTRIAGED | Not applied. | +| NET-R2-02 | UNTRIAGED | Not applied. | +| NET-R2-03 | FIXED | `InputBatch` was sent and never decoded; protocol 8 (`"RNE8"`) adds the decoder, a 255 cap and spectator support, and resends every authored run (`c752446f`). | +| NET-R2-04 | KEPT | The host now waits for its roster resends, which were never sent before; the stale comment rewritten (`c752446f`). | +| NET-R2-05 | UNTRIAGED | Not applied. | +| NET-R2-06 | FIXED | The change let two threads step one core; the emulation thread idles during netplay again and the UI thread keeps `try_lock` (`ddfc5f25`). | +| NET-R2-07 | REJECTED | The source filters ended a session on a NAT rebinding or an IPv4-mapped address and authenticated nothing (`c752446f`). DEFERRED as a design item: a per-session secret on every datagram. | +| NET-R2-08 | KEPT | The 5-second liveness check, without its `unwrap` (`ddfc5f25`). | +| NET-R2-09 | UNTRIAGED | Not applied. | + +## Mobile and libretro + +| ID | Verdict | What was done | +| --- | --- | --- | +| MOB-R2-01 | UNTRIAGED | Not applied. | +| AND-R2-01 | REJECTED | `write(byte[])` is not for a `PCM_FLOAT` track, and the blocking write is what paces the loop; the blocking write restored (maintainer, 2026-10-14). DEFERRED: non-blocking audio with its own pacing, on a device (`c04da0ca`). | +| IOS-R2-01 | CHANGED, UNVERIFIED | Zero-copy upload kept; the shim did not compile (`NES_W` not in scope) and had lost its SAFETY notes (`7086ae36`). Not compiled on this host (no Xcode); FIXED once the `ios` CI job builds it. | +| LIB-R2-01 | REVERTED | The video blit rewrite: unmeasured, and it needed a crate-wide `#![allow]` (`fa425caa`). | +| MOB-R2-02 | FIXED | Android's `updateScreenWakeLock` was never called; the emulation loop calls it now. iOS kept (`c04da0ca`). | +| AND-R2-02 | KEPT | `NesSurfaceView` clears `pendingSurface`. | +| MOB-R2-03 | KEPT | Fewer HD composite allocations; two crate-wide allows removed and their sites fixed (`c04da0ca`). | +| LIB-R2-02 | REVERTED | The audio push rewrite (unmeasured, crate-wide allow) (`fa425caa`). The core-options half was not applied: UNTRIAGED. | + +Mobile FDS saves (an earlier-round change in the same import) were rebuilt +on the desktop's `.fds.sav` design, with `NesController::boot_saved_disk` +keeping the pristine disk's identity (`c04da0ca`). DEFERRED with the Android +audio: the GPU-path frame-conversion skip, REVERTED because screenshots, +clips, casting and the netplay picture read that frame. + +## MiSTer RTL + +| ID | Verdict | What was done | +| --- | --- | --- | +| RTL-R2-01 | DEFERRED | To the sibling integration (consolidation plan, Step 5): re-derive in simulation first. Earlier RTL audits had a DC-blocker overflow claim refuted. | +| RTL-R2-02 | DEFERRED | Same. | +| RTL-R2-03 | DEFERRED | Same. | +| RTL-R2-04 | DEFERRED | Same; it audits an uncommitted palette split (salvaged, evaluated in Step 5). | +| RTL-R2-05 | DEFERRED | Same. | +| RTL-R2-06 | DEFERRED | Same. | +| RTL-R2-07 | DEFERRED | Same. | + +## Security + +| ID | Verdict | What was done | +| --- | --- | --- | +| SEC-R2-01 | REJECTED | `catch_unwind` on one of 15 cosim exports turned a panic into a cycle count of 0; an abort is the right failure for an oracle (`ac78f415`). | +| SEC-R2-02 | KEPT | Byte copy for an unaligned output buffer; SAFETY notes added, lint fixed (`ac78f415`). | +| SEC-R2-03 | REJECTED | The 64 MiB piccolo heap cap is checked only between executor steps, so it never fires: a string-doubling script grew to 3 GB inside a memory-capped jail before being killed (and, unconfined, to 62 GB + 30 GB swap) (`3b88005c`). A real limit needs a per-allocation hook that piccolo 0.3.3 does not have. | +| SEC-R2-04 | KEPT | `checked_add` on the trainer offset. | +| SEC-R2-05 | FIXED | Session seeds come from OS randomness (mobile, kept); the relay's own `getrandom` call ignored its seed and broke the wasm build, REVERTED (`c752446f`). | +| SEC-R2-06 | KEPT | Nullable callback as `Option`; SAFETY note rewritten (`0a6a08d2`). | +| SEC-R2-07 | KEPT | Token redaction in `Debug`, pinned by a test (`0a6a08d2`); new files created `0o600` (maintainer: adopt), tested and documented (`e222720c`). | +| SEC-R2-08 | FIXED | The rewrite did not compile and was no safer; replaced by a wasm-bindgen stack closure with no `unsafe` (`9273fe7c`). Compile-checked only: nothing calls the browser RetroAchievements path yet. | + +## Counts + +| Verdict | Findings | +| --- | --- | +| KEPT | 20 | +| FIXED | 14 | +| CHANGED, UNVERIFIED | 1 (IOS-R2-01) | +| REVERTED | 4 | +| REJECTED | 4 | +| UNTRIAGED | 11 | +| DEFERRED | 7 (RTL) | +| **Total** | **61** | + +One row per finding, counted from the tables above. LIB-R2-02 is counted as +REVERTED though its core-options half is untriaged; MAP-R2-03 as FIXED though +its FDS part was reverted. NET-R2-07 and AND-R2-01 also carry a deferred +follow-up. diff --git a/docs/audits/v3.2.0-round2-audit-report.md b/docs/audits/v3.2.0-round2-audit-report.md new file mode 100644 index 00000000..dd02ecfe --- /dev/null +++ b/docs/audits/v3.2.0-round2-audit-report.md @@ -0,0 +1,2721 @@ + + +# Round 2 Comprehensive Performance, Security, and Code Quality Audit + +**Target Repositories:** `RustyNES` (Branch `agy/perf_impr`) and `RustyNES_MiSTer` +**Date:** October 9, 2026 +**Audit Train:** Round 2 Deep-Dive Synthesis +**Provenance & Integrity Standard:** Clean-Room Black-Box Oracle Architecture (ADR 0037 & GEMINI.md Firewall Verified) + +--- + +## 1. Executive Summary + +This report delivers the comprehensive Round 2 audit of the **RustyNES** pure Rust cycle-accurate emulator workspace and the companion **RustyNES_MiSTer** SystemVerilog FPGA core. Following the full implementation and verification of Round 1 audit items (documented in `docs/Claude_Handoff.md`), this Round 2 investigation conducted a fresh, deep-dive examination across all eight major subsystems. + +The audit identified **61 entirely new, actionable findings** spanning core emulation accuracy, multi-threaded cache coherency, GPU shader WebGPU specification compliance, high-frequency FFI memory allocation storms, hardware RTL synthesizability on Intel Cyclone V FPGAs, netplay rollback simulation overhead, and C-ABI panic boundaries. Zero findings from Round 1 are repeated. + +### Comprehensive Metrics Table + +| Subsystem Domain | Critical | High | Medium | Low | Total Findings | +|---|:---:|:---:|:---:|:---:|:---:| +| **Section 1: APU & Audio Synthesis** | 0 | 2 | 3 | 3 | **8** | +| **Section 2: PPU & Rendering Shaders** | 0 | 3 | 2 | 2 | **7** | +| **Section 3: CPU, Core, Bus & Memory** | 1 | 1 | 4 | 1 | **7** | +| **Section 4: Mappers & GameDB** | 0 | 3 | 3 | 1 | **7** | +| **Section 5: Netplay, Rollback & State** | 1 | 6 | 2 | 0 | **9** | +| **Section 6: Mobile & Libretro Core** | 0 | 3 | 3 | 2 | **8** | +| **Section 7: MiSTer RTL & Hardware Architecture** | 1 | 3 | 3 | 0 | **7** | +| **Section 8: Security, FFI & Cryptography** | 0 | 4 | 3 | 1 | **8** | +| **Workspace Total** | **3** | **25** | **23** | **10** | **61** | + +### Comparison with Round 1 Scope + +| Metric / Dimension | Round 1 Audit (`docs/Claude_Handoff.md`) | Round 2 Audit (`docs/Round2-Audit.md`) | +|---|---|---| +| **Total Findings** | 13 remediations | **61 new findings** | +| **Core Schedule Model** | Verified 1-clock master cycle timebase | Audited sub-dot bus pipelining & interrupt tracing | +| **Memory & Serialization** | Fixed NET-01 leak & LIB-01 snapshot | Eradicated 245 KB netplay checkpoints & FFI storms | +| **FPGA RTL Scope** | Verified 192:1 palette mux split (RTL-01/02) | Identified 16K register BRAM inference & 24 ns divider delays | +| **Graphics & Shaders** | PPU fast-render documentation fix | W3C WGSL non-uniform derivative violations across 5 shaders | +| **Security & FFI** | Libretro C-ABI bounds checks | Workspace-wide unsafe audit, C-ABI panics, STUN CSPRNG | + +--- + +## 2. Scope, Methodology & Provenance Assurance + +### 2.1 Subsystem Scope + +The Round 2 audit examined all crates within the `RustyNES` cargo workspace and the hardware RTL repository: + +- **Audio & Synthesis**: `crates/rustynes-apu/` and `crates/rustynes-frontend/src/audio*.rs`. +- **Picture Processing & Shaders**: `crates/rustynes-ppu/`, `crates/rustynes-frontend/src/present_buffer.rs`, and `crates/rustynes-gfx-shaders/`. +- **CPU, Memory Bus & Core**: `crates/rustynes-cpu/` and `crates/rustynes-core/`. +- **Mappers & Game Database**: `crates/rustynes-mappers/` and `crates/rustynes-gamedb/`. +- **Network Play & State Management**: `crates/rustynes-netplay/` and `crates/rustynes-frontend/src/netplay_ui.rs`. +- **Mobile Platforms & Libretro**: `crates/rustynes-mobile/`, `crates/rustynes-android/`, `android/`, `crates/rustynes-ios/`, `ios/`, and `crates/rustynes-libretro/`. +- **Hardware FPGA Core**: `RustyNES_MiSTer/rtl/`, `RustyNES.sdc`, and Quartus compilation reports. +- **Security & Foreign Function Interface**: `crates/rustynes-cosim/`, `crates/rustynes-script/`, `crates/rustynes-cheevos/`, and workspace dependency manifests. + +### 2.2 Verification Methodology + +All findings were established through rigorous technical methodologies: + +1. **Static Analysis & Type Checking**: Verified symbol signatures, memory alignments, and trait dispatch patterns across the 2024 edition Rust codebase. +2. **Timing & Synthesis Reports**: Evaluated Intel Quartus Prime synthesis (`RustyNES.map.rpt`) and fitter (`RustyNES.fit.rpt`) logs on an Intel Cyclone V SoC FPGA (`5CSXFC6D6F31C6`). +3. **Specification Cross-Referencing**: Grounded all technical evaluations in authoritative public specifications: + - **NESdev Wiki**: Hardware register behavior for 2A03, 2C02, VRC2/4, Camerica, MMC5, and FDS. + - **W3C WebGPU & WGSL**: Implicit derivative calculations in fragment shader control flow (§13.3). + - **RFC 5389 / RFC 8489 / RFC 8656**: STUN/TURN 96-bit transaction ID cryptographic entropy standards. + - **IEEE 1800-2017 SystemVerilog LRM**: Synthesizability, non-blocking assignments, and clock domain crossing semantics. + - **The Rustonomicon & Rust Reference**: Memory safety invariants for `copy_nonoverlapping`, `transmute`, and C-ABI panic boundaries. + - **Intel Quartus Prime Pro Handbook**: True Dual-Port M10K BRAM inference and `lpm_divide` synthesis constraints. + +### 2.3 Strict Provenance Firewall Compliance (ADR 0037) + +In strict compliance with **ADR 0037** and the project license directives (`GEMINI.md`): + +- **Zero Third-Party Reference Emulator Source Code**: No reference emulator source code (Mesen2, puNES, FCEUX, Nestopia, higan, ares, or TriCNES) was opened, read, quoted, or transcribed. +- **Zero Third-Party RTL Core Code**: No third-party FPGA core source code (`NES_MiSTer`, `fpganes`) was accessed. +- **Pure Clean-Room Engineering**: All hardware behavior was evaluated exclusively against public hardware documentation, datasheets, public test ROM suites, and clean-room analytical deduction. + +--- + +## 3. Categorized Findings with Detailed Evidence and Drop-In Patches + +--- + +### Section 1: APU & Audio Synthesis Subsystem + +#### APU-R2-01: False Sharing & Cache-Line Contention Between Emulator and CPAL Audio Threads + +- **Severity / Category**: High / Concurrency & Cache Coherency +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/audio.rs:168-175` + +**Observation & Root Cause Analysis**: + +In `QueueInner`, the consumer index `head: AtomicUsize` and the producer index `tail: AtomicUsize` are stored consecutively in memory, immediately following the 24-byte `Ring` struct: + +```rust +struct QueueInner { + ring: Ring, + head: AtomicUsize, // Offset 24 + tail: AtomicUsize, // Offset 32 + overrun_dropped: AtomicU64, + underruns: AtomicU64, +} +``` + +Both `head` and `tail` occupy the same 64-byte L1/L2 data cache line. The emulation worker thread executes `push()` (frequently writing to `tail`), while the CPAL real-time audio thread executes `pop_or_silence()` (frequently writing to `head`). On multi-core CPUs, this causes constant cache-line invalidation (false sharing ping-pong) between CPU cores via the MESI cache protocol, introducing latency jitter into the audio thread. + +**Research Citations & Specifications**: + +*Intel 64 and IA-32 Architectures Optimization Reference Manual*, Section 3.7.3 ("Preventing False Sharing"): Atomic variables modified by distinct concurrent threads must reside on separate 64-byte cache lines to eliminate bus invalidation penalties. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-frontend/src/audio.rs ++++ b/crates/rustynes-frontend/src/audio.rs +@@ -167,8 +167,20 @@ ++/// Cache-line alignment wrapper (64 bytes) to eliminate false sharing ++/// between producer and consumer threads on multi-core processors. ++#[repr(align(64))] ++struct CacheAligned(pub T); ++ ++impl core::ops::Deref for CacheAligned { ++ type Target = T; ++ #[inline] ++ fn deref(&self) -> &Self::Target { ++ &self.0 ++ } ++} ++ + /// Shared queue state. + struct QueueInner { + ring: Ring, + /// Consumer (read) index — advanced by the audio callback only. +- head: AtomicUsize, ++ head: CacheAligned, + /// Producer (write) index — advanced by the app's produce path only. +- tail: AtomicUsize, ++ tail: CacheAligned, + /// Total samples dropped by `push` because the ring was full (overrun — +@@ -275,4 +287,4 @@ impl SampleQueue { + Self { + inner: Arc::new(QueueInner { + ring: Ring::new(cap), +- head: AtomicUsize::new(0), +- tail: AtomicUsize::new(0), ++ head: CacheAligned(AtomicUsize::new(0)), ++ tail: CacheAligned(AtomicUsize::new(0)), + overrun_dropped: AtomicU64::new(0), +``` + +--- + +#### APU-R2-02: Missing Integration of `add_sample_bounded` on APU Fast-Mix Hot Path + +- **Severity / Category**: High / Performance (1.79M Calls/Sec) +- **Target File & Line Numbers**: `crates/rustynes-apu/src/apu.rs:1316-1335` + +**Observation & Root Cause Analysis**: + +Round 1 implemented `BlipBuf::add_sample_bounded()` (`blip.rs:255`) to bypass redundant IEEE-754 `is_finite()` and `clamp(-4.0, 4.0)` floating-point checks for pre-bounded internal APU DAC outputs. However, `Apu::tick_with_external` at `apu.rs:1329` was never wired to call this method: + +```rust +let mixed = self.mixer.mix( + self.pulse1.output(), + self.pulse2.output(), + self.triangle.output(), + self.noise.output(), + self.dmc.output(), +) + external; +self.blip.add_sample(mixed); +``` + +When `external == 0.0` (standard cartridge without expansion sound), `self.mixer.mix()` looks up values strictly bounded in `[0.0, 1.0]`. Calling `add_sample` forces 1,789,773 redundant float classification checks and branch evaluations per second. + +**Research Citations & Specifications**: + +NESdev Wiki: "APU Mixer" — The non-linear 2A03 mixer produces normalized output strictly within $[0.0, 1.0]$. IEEE-754 `is_finite()` incurs CPU bitmask and branch overhead on every audio cycle. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-apu/src/apu.rs:1316-1335: + if mask == CHANNEL_MASK_ALL && self.gain_is_unity { + self.last_external = external; + let mixed = self.mixer.mix( + self.pulse1.output(), + self.pulse2.output(), + self.triangle.output(), + self.noise.output(), + self.dmc.output(), + ) + external; + #[cfg(feature = "debug-hooks")] + if self.audio_prov.is_some() { + self.record_mix_armed(mixed, external); + } + if external == 0.0 { + self.blip.add_sample_bounded(mixed); + } else { + self.blip.add_sample(mixed); + } + return; + } +``` + +--- + +#### APU-R2-03: Polyphase FIR BLEP Decimation Loop Non-Wrapping SIMD Vectorization + +- **Severity / Category**: Medium / SIMD Auto-Vectorization & Bounds Elision +- **Target File & Line Numbers**: `crates/rustynes-apu/src/blip.rs:292-314` + +**Observation & Root Cause Analysis**: + +In `BlipBuf::add_sample_bounded`: + +```rust +let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK; +let first = (RING_SIZE - start).min(TAPS); +for (slot, &coeff) in self.delta_ring[start..start + first].iter_mut().zip(&row[..first]) { + *slot += delta * coeff; +} +for (slot, &coeff) in self.delta_ring[..TAPS - first].iter_mut().zip(&row[first..]) { + *slot += delta * coeff; +} +``` + +`RING_SIZE = 4096`, `TAPS = 32`. In 4065 out of 4096 cases (**99.24% of all impulse scatters**), `start <= 4064`, so the 32-tap window is contiguous and does not wrap. Because `first` is dynamically calculated per call, LLVM cannot assume a compile-time static trip count of 32, generating slice bounds checks, scalar fallback tails, and an empty second loop. + +**Research Citations & Specifications**: + +LLVM Auto-Vectorization Guidelines: Loop trip counts statically known to match vector register widths (32 x 32-bit float = 4 x AVX2 or 8 x NEON registers) unroll cleanly into aligned SIMD FMA instructions with zero dynamic bounds checking. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-apu/src/blip.rs:292-314: + let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK; + if start <= RING_SIZE - TAPS { + let dest = &mut self.delta_ring[start..start + TAPS]; + for (slot, &coeff) in dest.iter_mut().zip(row) { + *slot += delta * coeff; + } + } else { + let first = RING_SIZE - start; + for (slot, &coeff) in self.delta_ring[start..RING_SIZE] + .iter_mut() + .zip(&row[..first]) + { + *slot += delta * coeff; + } + for (slot, &coeff) in self.delta_ring[..TAPS - first] + .iter_mut() + .zip(&row[first..]) + { + *slot += delta * coeff; + } + } +``` + +--- + +#### APU-R2-04: Static Dispatch & Branch Elimination in Analog Filter Chain + +- **Severity / Category**: Medium / Micro-Optimization & Branch Removal +- **Target File & Line Numbers**: `crates/rustynes-apu/src/mixer.rs:128-140, 217-221` + +**Observation & Root Cause Analysis**: + +`OnePole::process` evaluates `if self.is_hpf` on every audio sample. In `FilterChain`, `hp1` and `hp2` are statically known to be High-Pass Filters, while `lp` is statically known to be a Low-Pass Filter. Evaluating `if self.is_hpf` three times per sample on every output tick is completely redundant. + +**Research Citations & Specifications**: + +Agner Fog, *Optimizing Subroutines in Assembly Language*: Replace invariant runtime condition flags inside DSP loops with dedicated monomorphic subroutines to avoid branch prediction table pollution. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-apu/src/mixer.rs:128-140: +impl OnePole { + #[inline] + pub fn process_hpf(&mut self, x: f32) -> f32 { + let y = self.coeff * (self.prev_out + x - self.prev_in); + self.prev_in = x; + self.prev_out = y; + y + } + + #[inline] + pub fn process_lpf(&mut self, x: f32) -> f32 { + let y = self.prev_out + self.coeff * (x - self.prev_out); + self.prev_in = x; + self.prev_out = y; + y + } + + #[inline] + pub fn process(&mut self, x: f32) -> f32 { + if self.is_hpf { + self.process_hpf(x) + } else { + self.process_lpf(x) + } + } +} + +// In FilterChain::process around line 217: + #[inline] + pub fn process(&mut self, x: f32) -> f32 { + let a = self.hp1.process_hpf(x); + let b = self.hp2.process_hpf(a); + self.lp.process_lpf(b) + } +``` + +--- + +#### APU-R2-05: Redundant Floating-Point Division in Schroeder Reverb Real-Time DSP Loop + +- **Severity / Category**: Low / DSP Throughput +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/audio_dsp.rs:185-195` + +**Observation & Root Cause Analysis**: + +In `Reverb::process`, `acc /= self.combs.len().max(1) as f32` runs for every single audio sample. The comb filter count is a compile-time fixed constant of 4 (`comb_ms = [29.7, 37.1, 41.1, 43.7]`). The code performs integer-to-float conversion and a hardware floating-point division (`divss` instruction, 10–14 CPU cycles) instead of a single-cycle multiplication (`mulss`). + +**Research Citations & Specifications**: + +IEEE-754 Arithmetic Optimization: Floating-point multiplication by a reciprocal power-of-two constant ($0.25$) is exact and takes 1 CPU cycle versus 10-14 cycles for scalar division. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-frontend/src/audio_dsp.rs:185-195: + #[inline] + fn process(&mut self, x: f32) -> f32 { + let mut acc = 0.0; + for c in &mut self.combs { + acc += c.process(x); + } + acc *= 0.25; + for a in &mut self.allpasses { + acc = a.process(acc); + } + acc + } +``` + +--- + +#### APU-R2-06: Excessive 128 KiB Ring Buffer Footprint From `AtomicU64` Storing 32-Bit Samples + +- **Severity / Category**: Medium / Memory Footprint & L1 Cache Locality +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/audio.rs:142-161, 470-480, 540-550` + +**Observation & Root Cause Analysis**: + +`Ring::slots` is allocated as `Box<[AtomicU64]>` with `DEFAULT_CAPACITY = 16_384`. Each slot stores a 32-bit `f32` sample in the lower half of an `AtomicU64`, consuming `16,384 * 8 = 131,072 bytes (128 KiB)`. Standard L1 data cache is 32 KiB or 48 KiB on modern x86 and ARM processors. This 128 KiB buffer overflows L1D cache, causing L2 cache misses on audio stream pushes and pops. Storing raw float bits in `AtomicU32` halves the footprint to 64 KiB. + +**Research Citations & Specifications**: + +*Computer Architecture: A Quantitative Approach*: Keeping streaming ring buffers compact reduces L1D/L2 cache evictions during audio frame processing. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-frontend/src/audio.rs:142-161: +struct Ring { + slots: Box<[AtomicU32]>, + mask: usize, +} + +impl Ring { + fn new(capacity_pow2: usize) -> Self { + debug_assert!(capacity_pow2.is_power_of_two()); + let slots = (0..capacity_pow2) + .map(|_| AtomicU32::new(0)) + .collect::>() + .into_boxed_slice(); + Self { + slots, + mask: capacity_pow2 - 1, + } + } +} + +// In push(): + for (i, &s) in samples[..n].iter().enumerate() { + self.inner.ring.slots[tail.wrapping_add(i) & self.inner.ring.mask] + .store(s.to_bits(), Ordering::Relaxed); + } + +// In pop_or_silence(): + for (i, o) in out[..n].iter_mut().enumerate() { + let bits = self.inner.ring.slots[head.wrapping_add(i) & self.inner.ring.mask] + .load(Ordering::Relaxed); + *o = f32::from_bits(bits) * gain; + } +``` + +--- + +#### APU-R2-07: Contradiction Between Code (Increment) and Spec (Decrement) in Pulse Duty Sequencer + +- **Severity / Category**: Low / Documentation & Accuracy +- **Target File & Line Numbers**: `crates/rustynes-apu/src/pulse.rs:28, 128-129` + +**Observation & Root Cause Analysis**: + +Line 28 (`step: u8`) and line 128 state: "8-step duty sequencer, decremented (NESdev wiki)". However, line 129 executes `self.step = (self.step + 1) & 0x07;`, which increments `step`. In 2A03 hardware, the physical duty sequencer is a 3-bit down-counter, but RustyNES pre-maps `DUTY_TABLE` chronologically from left to right. The code is functionally correct, but the comment contradicts the code. + +**Research Citations & Specifications**: + +NESdev Wiki: "APU Pulse" — Physical hardware counts backwards from 7 down to 0; emulator lookup tables ordered chronologically must increment the table pointer. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-apu/src/pulse.rs:28: + /// Step index into the duty table (0..=7). Incremented on each timer underflow + /// (the 2A03 hardware down-counter sequence is mapped chronologically into DUTY_TABLE). + pub(crate) step: u8, + +// In crates/rustynes-apu/src/pulse.rs:128: + // 8-step duty sequencer. The hardware down-counter sequence is mapped + // chronologically in DUTY_TABLE, so step is incremented here. + self.step = (self.step + 1) & 0x07; +``` + +--- + +#### APU-R2-08: Inconsistent LFSR Mode Description in Noise Channel Documentation + +- **Severity / Category**: Low / Documentation & Accuracy +- **Target File & Line Numbers**: `crates/rustynes-apu/src/noise.rs:6, 28` + +**Observation & Root Cause Analysis**: + +Line 6 correctly documents Mode 1 as: "Mode 1 (short): feedback = bit 0 XOR bit 6, 93-bit period". In contrast, line 28 states: "Mode (false = long / 15-bit, true = short / 6-bit)". Describing Mode 1 as a "6-bit LFSR" is inaccurate; the shift register is 15 bits wide in both modes, with Mode 1 tapping bit 6 to produce a 93-step sequence. + +**Research Citations & Specifications**: + +NESdev Wiki: "APU Noise" — The 2A03 LFSR has 15 bits of state in both modes; the short mode selects bit 6 instead of bit 1 for feedback. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-apu/src/noise.rs:28: + /// Mode (false = long / 15-bit, true = short / 93-step mode taking feedback from bit 6). + pub(crate) mode: bool, +``` + +--- + +### Section 2: PPU & Rendering Shaders + +#### PPU-R2-01: `cached_emph_offset` Unsynchronized on Savestate Restore + +- **Severity / Category**: High / Correctness & Savestate Determinism +- **Target File & Line Numbers**: `crates/rustynes-ppu/src/snapshot.rs:701, 899-915` + +**Observation & Root Cause Analysis**: + +In commit `4ddeddf9`, `cached_emph_offset` was introduced to eliminate per-pixel emphasis branching in `emit_pixel`. In `snapshot.rs:701`, `self.mask` is restored from snapshot data, but lines 899-913 (the derived-cache fixup block) fail to call `self.update_emph_offset()`. When restoring a save state during netplay rollback or rewind, `cached_emph_offset` retains its pre-restore value. If emphasis was active in one state and inactive in another, palette colors render with incorrect emphasis offsets until the CPU next writes to `$2001`. + +**Research Citations & Specifications**: + +NESdev Wiki: "Colour emphasis" and "PPU registers ($2001)". Savestate restore routines must recompute all derived state caches from deserialized hardware registers. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-ppu/src/snapshot.rs ++++ b/crates/rustynes-ppu/src/snapshot.rs +@@ -911,6 +911,7 @@ impl Ppu { + self.cached_idle_line = false; + } + self.flags_cached_scanline = i16::MIN; ++ self.update_emph_offset(); + + // sanity: the schema-fixed sizes mean we should be at end of input now. + if r.pos != data.len() { +``` + +--- + +#### PPU-R2-02: WebGPU WGSL Non-Uniform Derivative Violations in Post-Processing Shaders + +- **Severity / Category**: High / WebGPU Specification Compliance & Driver Portability +- **Target File & Line Numbers**: + - `crates/rustynes-gfx-shaders/src/lib.rs:112` (`CRT_WGSL`) + - `crates/rustynes-gfx-shaders/src/lib.rs:280` (`NTSC_LMP_WGSL`) + - `crates/rustynes-gfx-shaders/src/crt_guest.wgsl:132` + - `crates/rustynes-gfx-shaders/src/crt_royale.wgsl:137` + - `crates/rustynes-gfx-shaders/src/megatron.wgsl:118` + +**Observation & Root Cause Analysis**: + +In `CRT_WGSL`, `NTSC_LMP_WGSL`, and the ported guest/royale/megatron shaders, the fragment entry points execute an early-exit return for letterbox borders before invoking `textureSample`: + +```wgsl +if (in.uv.x < 0.0 || in.uv.x > 1.0 || in.uv.y < 0.0 || in.uv.y > 1.0) { + return vec4(0.0, 0.0, 0.0, 1.0); +} +var rgb = textureSample(nes_tex, nes_smp, suv).rgb; +``` + +`textureSample` calculates implicit screen-space derivatives across the 2x2 fragment quad. Invoking `textureSample` in non-uniform control flow violates WGSL §13.3, causing undefined derivative behavior, border rendering glitches, or compilation failures on strict drivers (Metal, Vulkan, Android Mali/Adreno). Because NES textures have no mipmaps, `textureSampleLevel(nes_tex, nes_smp, suv, 0.0)` is strictly legal and faster. + +**Research Citations & Specifications**: + +W3C WebGPU Shading Language (WGSL) Specification, Section 13.3 ("Texture Sample Operations"): Implicit derivative operations must only be executed in uniform control flow. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-gfx-shaders/src/lib.rs ++++ b/crates/rustynes-gfx-shaders/src/lib.rs +@@ -111,3 +111,3 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { + let suv = vec2(in.uv.x * u.crop.z + u.crop.w, in.uv.y * u.crop.x + u.crop.y); +- var rgb = textureSample(nes_tex, nes_smp, suv).rgb; ++ var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb; +@@ -280,3 +280,3 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { + let uvx = samp_col * texel; +- let src = textureSample(nes_tex, nes_smp, vec2(uvx, suv.y)).rgb; ++ let src = textureSampleLevel(nes_tex, nes_smp, vec2(uvx, suv.y), 0.0).rgb; +``` + +--- + +#### PPU-R2-03: Redundant Dynamic Texture Loads and Uniform Queries in `signal_decode.wgsl` + +- **Severity / Category**: Medium / Shader GPU Memory Bandwidth +- **Target File & Line Numbers**: `crates/rustynes-gfx-shaders/src/signal_decode.wgsl:96-105, 186-205` + +**Observation & Root Cause Analysis**: + +The NTSC demodulation filter evaluates a 49-tap window per pixel (`half` up to 24). In every iteration, `composite_at` is called, which invokes `textureDimensions(idx_tex)` and `textureLoad(idx_tex, coords)`. However, `col = i32(floor(abs_sub / 8.0))` only changes once every 8 sub-samples. `textureLoad` is called 49 times for only 6-7 distinct texels per fragment, causing severe texture cache pressure. + +**Research Citations & Specifications**: + +WebGPU Performance Best Practices: Avoid redundant texel loads across sub-sample loops by caching the active texel across loop iterations. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl ++++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl +@@ -96,8 +96,8 @@ fn pmod(x: i32, n: i32) -> i32 { +-fn composite_at(col: i32, sub: i32, row: i32, line_phase: i32) -> f32 { +- let dim = textureDimensions(idx_tex); +- let cx = clamp(col, 0, i32(dim.x) - 1); +- let cy = clamp(row, 0, i32(dim.y) - 1); +- let packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r); ++fn composite_at_packed(packed: i32, col: i32, sub: i32, line_phase: i32) -> f32 { + let index = packed & 0x3F; +@@ -186,2 +186,4 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { + var w_sum = 0.0; ++ var last_col: i32 = -999999; ++ var packed: i32 = 0; + for (var s = -half; s <= half; s = s + 1) { +@@ -190,3 +192,8 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { + let col = i32(floor(abs_sub / 8.0)); + let sub = i32(floor(abs_sub)) % 8; +- let comp = composite_at(col, sub, row, line_phase); ++ if (col != last_col) { ++ let cx = clamp(col, 0, i32(dim.x) - 1); ++ let cy = clamp(row, 0, i32(dim.y) - 1); ++ packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r); ++ last_col = col; ++ } ++ let comp = composite_at_packed(packed, col, sub, line_phase); +``` + +--- + +#### PPU-R2-04: Triple-Buffer Lock Contention & Redundant 245 KiB Memcpy in `PresentBuffer::take_into` + +- **Severity / Category**: High / Thread Contention & Memory Bandwidth +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/present_buffer.rs:188-192` + +**Observation & Root Cause Analysis**: + +`PresentBuffer::take_into` executes `out.extend_from_slice(&slots.bufs[ready])` while holding `self.slots.lock()`. Copying 245,760 bytes under the mutex locks out the emulation thread for tens of microseconds per frame. Because the caller passes an owned `out: &mut Vec`, using `std::mem::swap(&mut slots.bufs[ready], out)` replaces the 245 KiB memcpy with an $O(1)$ 24-byte pointer swap, reducing lock duration to nanoseconds. + +**Research Citations & Specifications**: + +Anthony Williams, *C++ Concurrency in Action* (Double/Triple Buffering Lock Minimization): Lock critical sections in triple-buffered rendering queues must perform atomic index or pointer swaps rather than copying buffer contents. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-frontend/src/present_buffer.rs ++++ b/crates/rustynes-frontend/src/present_buffer.rs +@@ -188,4 +188,4 @@ impl PresentBuffer { + slots.index = Self::pack(ready, front, back); +- out.clear(); +- out.extend_from_slice(&slots.bufs[ready]); +- slots.stamps[ready] ++ let stamp = slots.stamps[ready]; ++ std::mem::swap(&mut slots.bufs[ready], out); ++ stamp + } +``` + +--- + +#### PPU-R2-05: Redundant Address Arithmetic & Duplicate Check in `Ppu::emit_pixel` + +- **Severity / Category**: Medium / Hot-Path Instruction Count (61,440 Dots/Frame) +- **Target File & Line Numbers**: `crates/rustynes-ppu/src/ppu.rs:5126-5130, 5177-5181, 5253-5282` + +**Observation & Root Cause Analysis**: + +`emit_pixel` runs 61,440 times per frame. At line 5253, `let off = ((pixel_y as usize) * 256 + pixel_x as usize) * 4` calculates the byte offset, and line 5280 evaluates `self.index_framebuffer[off >> 2] = lut_idx as u16`, reversing the multiplication. In addition, `self.mask.contains(PpuMask::SHOW_SPRITE) && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT))` is evaluated twice on every pixel. + +**Research Citations & Specifications**: + +Agner Fog, *Optimizing Software in C++*: Hoisting common sub-expressions and eliminating address calculation round-trips (`* 4` followed by `>> 2`) eliminates redundant arithmetic operations on inner pixel rasterization loops. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-ppu/src/ppu.rs ++++ b/crates/rustynes-ppu/src/ppu.rs +@@ -5126,3 +5126,3 @@ impl Ppu { +- if self.mask.contains(PpuMask::SHOW_SPRITE) +- && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) +- { ++ let sprites_active = self.mask.contains(PpuMask::SHOW_SPRITE) ++ && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)); ++ if sprites_active { +@@ -5179,2 +5179,2 @@ impl Ppu { +- && self.mask.contains(PpuMask::SHOW_SPRITE) +- && (pixel_x >= 8 || self.mask.contains(PpuMask::SHOW_SPRITE_LEFT)) ++ && sprites_active +@@ -5253,2 +5253,3 @@ impl Ppu { +- let off = ((pixel_y as usize) * 256 + pixel_x as usize) * 4; ++ let px = (pixel_y as usize) * 256 + pixel_x as usize; ++ let off = px * 4; +@@ -5280,1 +5281,1 @@ impl Ppu { +- self.index_framebuffer[off >> 2] = lut_idx as u16; ++ self.index_framebuffer[px] = lut_idx as u16; +``` + +--- + +#### PPU-R2-06: Redundant Writes During Secondary OAM Clear (Dots 1..=64) in `tick_oam_bus` + +- **Severity / Category**: Low / Hardware Parity & Cache Writes +- **Target File & Line Numbers**: `crates/rustynes-ppu/src/ppu.rs:5656-5658` + +**Observation & Root Cause Analysis**: + +In `tick_oam_bus`, `self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF;` executes on every dot from 1 to 64, performing 64 stores to a 32-element array. On real 2C02 hardware, secondary OAM writes occur strictly on even clock cycles. + +**Research Citations & Specifications**: + +NESdev Wiki: "PPU Sprite Evaluation" — Cycles 1-64 clear secondary OAM at a rate of 1 byte per 2 PPU dots (even cycles). + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-ppu/src/ppu.rs ++++ b/crates/rustynes-ppu/src/ppu.rs +@@ -5656,3 +5656,5 @@ impl Ppu { + self.oam_bus_copybuffer = 0xFF; +- self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF; ++ if (cycle & 1) == 0 { ++ self.oam_bus_secondary[((cycle - 1) >> 1) as usize] = 0xFF; ++ } + return; +``` + +--- + +#### PPU-R2-07: Incomplete Documentation for `cached_emph_offset` + +- **Severity / Category**: Low / Documentation & Maintainability +- **Target File & Line Numbers**: `crates/rustynes-ppu/src/ppu.rs:461-463, 2129` + +**Observation & Root Cause Analysis**: + +`cached_emph_offset: usize` was introduced in commit `4ddeddf9` without doc comments explaining its memory layout, lifecycle, or the bit permutation required for PAL systems. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-ppu/src/ppu.rs ++++ b/crates/rustynes-ppu/src/ppu.rs +@@ -461,2 +461,6 @@ pub struct Ppu { + pub(crate) mask: PpuMask, ++ /// Cached emphasis LUT offset `(emph << 6)` for fast indexing into [`Self::rgba_lut`]. ++ /// Maintained by [`Self::update_emph_offset`] on reset, `$2001` writes, and snapshot restore. + pub(crate) cached_emph_offset: usize, +``` + +--- + +### Section 3: CPU, Core, Bus & Memory Architecture + +#### CORE-R2-01: Interrupt Service Tracing & Hijack Discrimination (`is_nmi` Always False) + +- **Severity / Category**: **Critical** | Hardware Parity & Observability +- **Target File & Line Numbers**: `crates/rustynes-cpu/src/cpu.rs:513, 1084-1098, 2189` and `crates/rustynes-core/src/bus.rs:4304-4317, 4338-4342` + +**Observation & Root Cause Analysis**: +In `crates/rustynes-cpu/src/cpu.rs`, `service_interrupt` is called from `step()` (line 513) and `dispatch` (line 2189) with `vector` passed as `IRQ_VECTOR` (`0xFFFE`). At line 1098: + +```rust +let effective_vector = if self.mc_prev_need_nmi && vector != NMI_VECTOR { + NMI_VECTOR +} else { vector }; +bus.notify_irq_service(effective_vector, vector == NMI_VECTOR); +``` + +`vector == NMI_VECTOR` evaluates `0xFFFE == 0xFFFA`, which is **always false**. Even when an NMI takes priority and `effective_vector` resolves to `0xFFFA`, `is_nmi` is passed as `false`. In `bus.rs:4338`: + +```rust +let kind = if is_nmi { ServiceKind::Nmi } else { ServiceKind::Irq }; +``` + +Because `is_nmi` is unconditionally false, every NMI service event is erroneously stamped as `ServiceKind::Irq` in the hardware timing trace. + +**Research Citations & Specifications**: +NESdev Wiki: "CPU Interrupts" — NMI hijacking of IRQ/BRK occurs when an NMI edge is detected during the vector-fetch sequence, causing the 6502 to fetch from `$FFFA` instead of `$FFFE`. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-cpu/src/cpu.rs ++++ b/crates/rustynes-cpu/src/cpu.rs +@@ -1098,1 +1098,1 @@ impl Cpu { +- bus.notify_irq_service(effective_vector, vector == NMI_VECTOR); ++ bus.notify_irq_service(effective_vector, effective_vector == NMI_VECTOR); + +--- a/crates/rustynes-core/src/bus.rs ++++ b/crates/rustynes-core/src/bus.rs +@@ -4338,3 +4338,3 @@ impl SystemBus { +- let kind = if is_nmi { ++ let kind = if is_nmi || vector == 0xFFFA { + crate::irq_trace::ServiceKind::Nmi + } else { +``` + +--- + +#### CORE-R2-02: Dynamic Dispatch Overhead on PRG-ROM Unmapped Reads in `SystemBus::raw_cpu_read` + +- **Severity / Category**: High / Memory Bus Performance +- **Target File & Line Numbers**: `crates/rustynes-core/src/bus.rs:3804-3828` and `crates/rustynes-mappers/src/mapper.rs:160-197, 247-250` + +**Observation & Root Cause Analysis**: +For all CPU instruction fetches and memory operand reads in `$4020..=0xFFFF` (>90% of all CPU reads), `raw_cpu_read` performs an indirect vtable call via `Box`: + +```rust +if self.mapper.cpu_read_unmapped(addr) { + self.open_bus +} else { + self.mapper.cpu_read(addr) +} +``` + +Under default mapper semantics, unmapped open-bus reads occur exclusively in `$4020..=$5FFF` (unmapped cartridge expansion space) and `$6000..=$7FFF` (when PRG-RAM is absent). On standard discrete and ASIC boards, PRG-ROM in `$8000..=$FFFF` is never unmapped. +Adding a `has_unmapped_reads` capability flag to `MapperCaps` would regress hardware accuracy: defaulting discrete mappers to `false` would bypass `cpu_read_unmapped` for `$4020..=$5FFF`, returning `0` from `mapper.cpu_read()` rather than open bus, causing a regression on AccuracyCoin CPU Open Bus Test 1 (`LDA $5000` must return `$50`, not `$00`). +Instead, hoisting the address check directly in `SystemBus::raw_cpu_read` (`addr < 0x8000`) completely eliminates the virtual method dispatch for all PRG-ROM reads in `$8000..=$FFFF` (>90% of bus cycles), while preserving 100% bit-accurate open-bus behavior on `$4020..=$5FFF` and unmapped PRG-RAM without modifying `MapperCaps`. + +**Research Citations & Specifications**: +*Rust Performance Book*: Virtual method calls through `dyn Trait` prevent inlining and force indirect branch prediction overhead on hot memory buses. Hoisting constant address bounds checks (`addr < 0x8000`) allows the fast path for PRG-ROM execution to eliminate indirect branching entirely. +*NESdev Wiki*, "Open Bus Behavior": Unmapped reads outside PRG-ROM return floating bus latch values; `$8000..=$FFFF` is always driven on standard boards. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-core/src/bus.rs ++++ b/crates/rustynes-core/src/bus.rs +@@ -3804,3 +3804,3 @@ impl SystemBus { + 0x4020..=0xFFFF => { +- if self.mapper.cpu_read_unmapped(addr) { ++ if addr < 0x8000 && self.mapper.cpu_read_unmapped(addr) { + // Unmapped read: nothing drives the bus, so the CPU sees +``` + +--- + +#### CORE-R2-03: Dead Pre-v2.0.0 Interrupt Latches & Redundant Per-Instruction Promotion in `Cpu::step` + +- **Severity / Category**: Medium / Code Cleanliness & Execution Overhead +- **Target File & Line Numbers**: `crates/rustynes-cpu/src/cpu.rs:518-528, 591, 601-635` and `crates/rustynes-core/src/bus.rs:4606-4608` + +**Observation & Root Cause Analysis**: +`self.promote_post_step_interrupts(cycles)` is called after every instruction in `Cpu::step`. A workspace grep confirms that `armed_nmi` and `armed_irq` are never read anywhere in the codebase. Interrupts are dispatched purely through `self.mc_prev_run_irq || self.mc_prev_need_nmi`. Calling `promote_post_step_interrupts` executes dead latch updates across millions of instructions. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-cpu/src/cpu.rs ++++ b/crates/rustynes-cpu/src/cpu.rs +@@ -591,1 +591,0 @@ impl Cpu { +- self.promote_post_step_interrupts(cycles); + +--- a/crates/rustynes-core/src/bus.rs ++++ b/crates/rustynes-core/src/bus.rs +@@ -4606,3 +4606,3 @@ impl SystemBus { + // v2.5.1 (ADR 0038). Injected here, on the LEVEL: the production CPU +- // samples it every cycle and edge-detects it itself (`nmi_first_tick` +- // -> `pending_nmi` -> `armed_nmi`). ++ // samples it every cycle and edge-detects it itself (`mc_need_nmi` ++ // -> `mc_prev_need_nmi`). +``` + +--- + +#### CORE-R2-04: Match Indirection Pointer Dereference Penalty in 6502 `Cpu::dispatch` + +- **Severity / Category**: Medium / Register Allocation & ABI Efficiency +- **Target File & Line Numbers**: `crates/rustynes-cpu/src/cpu.rs:554-555, 1284-2671` + +**Observation & Root Cause Analysis**: +`dispatch` takes `cycles: &mut u8`, forcing all 256 match arms to execute a pointer dereference write (`*cycles = N`). Passing a mutable reference forces a stack spill and prevents LLVM from evaluating the cycle count directly in a CPU register (`RAX`/`W0`). + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-cpu/src/cpu.rs:554-555: + let cycles = self.dispatch(bus, opcode); + +// In crates/rustynes-cpu/src/cpu.rs:1284: + fn dispatch(&mut self, bus: &mut B, op: u8) -> u8 { + match op { + 0xA9 => { + let v = self.fetch_pc(bus); + self.lda(v); + 2 + } +``` + +--- + +#### CORE-R2-05: Redundant Stack Construction of `PpuBusAdapter` in `SystemBus::run_ppu_to` + +- **Severity / Category**: Medium / Compiler Optimization +- **Target File & Line Numbers**: `crates/rustynes-core/src/bus.rs:4451-4465` + +**Observation & Root Cause Analysis**: +`run_ppu_to` runs twice per CPU cycle. In each iteration of the while loop, `PpuBusAdapter` is constructed on the stack, re-borrowing `self.mapper.as_mut()`. Constructing it once outside the loop and updating `adapter.sub_dot` eliminates repeated fat-pointer re-borrowing. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-core/src/bus.rs ++++ b/crates/rustynes-core/src/bus.rs +@@ -4451,7 +4451,8 @@ impl SystemBus { ++ let mut adapter = PpuBusAdapter { ++ mapper: self.mapper.as_mut(), ++ nt_override: self.nt_mirroring_override, ++ sub_dot, ++ }; + while self.ppu_clock + ppu_div <= target { +- let mut adapter = PpuBusAdapter { +- mapper: self.mapper.as_mut(), +- nt_override: self.nt_mirroring_override, +- sub_dot, +- }; ++ adapter.sub_dot = sub_dot; + self.ppu.tick(&mut adapter); +``` + +--- + +#### CORE-R2-06: Cache Line Inefficiency in `SystemBus` Struct Layout + +- **Severity / Category**: Medium / Data Cache Locality +- **Target File & Line Numbers**: `crates/rustynes-core/src/bus.rs:330-811` + +**Observation & Root Cause Analysis**: +Scalar execution fields accessed on every master cycle (`cycle`, `ppu_clock`, `cpu_div_cached`, `ppu_div_cached`, `open_bus`, `internal_data_bus`, `last_read_addr`) are dispersed across hundreds of lines and separated by large sub-structures (`Ppu`, `Apu`, `Cartridge`), causing avoidable L1 data cache misses. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-core/src/bus.rs ++++ b/crates/rustynes-core/src/bus.rs +@@ -330,2 +330,24 @@ pub struct SystemBus { ++ // Hot execution scalar fields clustered in the leading 64-byte L1 cache line: ++ pub(crate) cycle: u64, ++ ppu_clock: u64, ++ apu_cycle: u64, ++ last_read_addr: u16, ++ deferred_dma_replay_addr: u16, ++ dma_halt_addr: u16, ++ cpu_div_cached: u8, ++ ppu_div_cached: u8, ++ cpu_div_effective: u8, ++ cpu_overclock: u8, ++ overclock_phase: u8, ++ open_bus: u8, ++ internal_data_bus: u8, ++ stock_step: bool, ++ in_dmc_dma: bool, ++ dmc_halt: bool, ++ dmc_load_write_delayed: bool, ++ uni_oam_active: bool, ++ uni_oam_halt: bool, ++ uni_oam_aligned: bool, ++ + /// CPU RAM (2 KiB), mirrored every 0x800 bytes from `$0000-$1FFF`. +@@ -536,3 +558,0 @@ pub struct SystemBus { +- pub(crate) cycle: u64, +- + /// OAM DMA pending source page (set by `$4014` write; consumed on the +@@ -570,11 +590,0 @@ pub struct SystemBus { +- ppu_clock: u64, +- cpu_div_cached: u8, +- ppu_div_cached: u8, +- cpu_overclock: u8, +- mmc3_revision_override: Option, +- cpu_div_effective: u8, +- overclock_phase: u8, +- apu_cycle: u64, +- stock_step: bool, +- open_bus: u8, +- internal_data_bus: u8, +@@ -582,10 +592,2 @@ pub struct SystemBus { ++ mmc3_revision_override: Option, +- last_read_addr: u16, +- deferred_dma_replay_addr: u16, +- in_dmc_dma: bool, +- dmc_halt: bool, +- dmc_load_write_delayed: bool, +- uni_oam_active: bool, +- uni_oam_halt: bool, +- uni_oam_aligned: bool, +``` + +--- + +#### CORE-R2-07: Stale Architecture Documentation in `crates/rustynes-core/src/nes.rs` + +- **Severity / Category**: Low / Documentation Accuracy +- **Target File & Line Numbers**: `crates/rustynes-core/src/nes.rs:186` + +**Observation & Root Cause Analysis**: +Line 186 states: `/// Owns the CPU, PPU, mapper, RAM, and controller stub.` The phrase "controller stub" is obsolete; the controller subsystem was expanded into a complete peripheral simulation architecture supporting dual standard controllers, Four Score, Zapper, and Arkanoid paddles. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-core/src/nes.rs ++++ b/crates/rustynes-core/src/nes.rs +@@ -186,1 +186,1 @@ +-/// Owns the CPU, PPU, mapper, RAM, and controller stub. ++/// Owns the CPU, PPU, mapper, RAM, audio subsystem, and peripheral input devices. +``` + +--- + +### Section 4: Mappers & GameDB + +#### MAP-R2-01: Table-Based CRC32 & ROM Clone Elimination in GameDB + +- **Severity / Category**: High / Performance & Startup Latency +- **Target File & Line Numbers**: `crates/rustynes-gamedb/src/lib.rs:707-720, 759, 966-979` + +**Observation & Root Cause Analysis**: +`crc32` in `gamedb` computes IEEE CRC-32 bit-by-bit (8 iterations per byte with branches). For large 1-4 MiB cartridges, this executes up to 32 million loop iterations. In `ines1_as_nes2`, `let mut image = bytes.to_vec()` clones the entire ROM up to 32 times inside a candidate loop (up to 32 MiB of allocations). In `same_board`, `format!("{:?}", ma.debug_info())` formats structs to strings for equality comparison. + +**Research Citations & Specifications**: +Hacker's Delight, Chapter 14: Table-driven CRC-32 replaces bitwise loops with 1 table lookup per byte. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-gamedb/src/lib.rs: +const CRC32_TABLE: [u32; 256] = { + let mut table = [0u32; 256]; + let mut i = 0; + while i < 256 { + let mut crc = i as u32; + let mut j = 0; + while j < 8 { + crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + j += 1; + } + table[i] = crc; + i += 1; + } + table +}; + +fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc = (crc >> 8) ^ CRC32_TABLE[((crc ^ u32::from(byte)) & 0xFF) as usize]; + } + !crc +} + +// In ines1_as_nes2 around line 707: +let mut image = bytes.to_vec(); +for sub in 0..16 { + header[8] = sub << 4; + for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] { + header[10] = byte10; + header[11] = byte11; + image[..16].copy_from_slice(&header); + if let Ok(promoted) = parse(&image) + && same_board(&original, &promoted) + { + return Some(header); + } + } +} +``` + +--- + +#### MAP-R2-02: Eliminate Integer Division from Hot Read Paths in Discrete & Major Mappers + +- **Severity / Category**: High / Hot-Path Fetch Performance +- **Target File & Line Numbers**: +- `crates/rustynes-mappers/src/m002_uxrom.rs:105-120` +- `crates/rustynes-mappers/src/m007_axrom.rs:82-87` +- `crates/rustynes-mappers/src/m003_cnrom.rs:121-125` +- `crates/rustynes-mappers/src/m066_gxrom.rs:71-76` +- `crates/rustynes-mappers/src/m004_mmc3.rs:665, 798` + +**Observation & Root Cause Analysis**: +In `UxRom`, `AxRom`, `CnRom`, and `GxRom`, `bank_count = len / BANK_SIZE` and `% bank_count` are recomputed on every CPU instruction fetch and PPU pattern read. In `MMC3`, lines 665 and 798 evaluate `off % len` on every read despite bank offsets already being precomputed. + +**Research Citations & Specifications**: +*Agner Fog Instruction Tables*: 64-bit integer division (`divq`) incurs 20-30 cycles of latency on modern x86/ARM CPUs. Bank indices change only during mapper register writes and should be precomputed. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-mappers/src/m002_uxrom.rs ++++ b/crates/rustynes-mappers/src/m002_uxrom.rs +@@ -42,2 +42,4 @@ pub struct UxRom { + bank: u8, ++ bank_offset: usize, ++ last_bank_offset: usize, + mirroring: Mirroring, +@@ -78,8 +80,12 @@ impl UxRom { ++ let bank_count = (prg_rom.len() / PRG_BANK_16K).max(1); ++ let last_bank_offset = (bank_count - 1) * PRG_BANK_16K; + Ok(Self { + prg_rom, + chr, + vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(), + chr_is_ram, + bank: 0, ++ bank_offset: 0, ++ last_bank_offset, + mirroring, + }) +@@ -93,2 +99,9 @@ impl UxRom { + } ++ ++ #[inline] ++ fn update_bank_offsets(&mut self) { ++ let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1); ++ self.bank_offset = ((self.bank as usize) % bank_count) * PRG_BANK_16K; ++ self.last_bank_offset = (bank_count - 1) * PRG_BANK_16K; + } + } +@@ -106,4 +119,3 @@ impl Mapper for UxRom { + 0x8000..=0xBFFF => { +- let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1); +- let bank = (self.bank as usize) % bank_count; + let off = (addr - 0x8000) as usize; +- self.prg_rom[bank * PRG_BANK_16K + off] ++ self.prg_rom[self.bank_offset + off] + } + 0xC000..=0xFFFF => { +- let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1); +- let last = bank_count - 1; + let off = (addr - 0xC000) as usize; +- self.prg_rom[last * PRG_BANK_16K + off] ++ self.prg_rom[self.last_bank_offset + off] + } +@@ -131,2 +143,3 @@ impl Mapper for UxRom { + self.bank = value & mask; ++ self.update_bank_offsets(); + } +@@ -213,2 +226,3 @@ impl Mapper for UxRom { + self.bank = data[1]; ++ self.update_bank_offsets(); + let mut cursor = 2; +``` + +--- + +#### MAP-R2-03: Expansion Audio Synthesis Optimizations (Sunsoft 5B, FDS & MMC5) + +- **Severity / Category**: Medium / Audio Synthesis Performance +- **Target File & Line Numbers**: `crates/rustynes-mappers/src/m069_sunsoft_fme7.rs:521-550`, `fds.rs:1037-1060`, `m005_mmc5.rs:326-330` + +**Observation & Root Cause Analysis**: +Sunsoft 5B's `mix()` executes a 3-channel loop and division by 138 on every CPU cycle. FDS audio performs two separate divisions by `den` per cycle. In MMC5 pulse output, `self.muted()` executes two comparisons before `DUTY_TABLE == 0` is checked. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-mappers/src/m005_mmc5.rs:326-330: + pub(crate) fn output(&self) -> u8 { + if DUTY_TABLE[self.duty as usize][self.step as usize] == 0 || self.muted() { + 0 + } else { + self.envelope_output() + } + } + +// In crates/rustynes-mappers/src/fds.rs:1037-1060: + pub(crate) fn output(&self) -> i16 { + let gain = u32::from(self.vol_gain.min(32)); + let sample = u32::from(self.wave_out_latch & 0x3F); + let den = u32::from(self.master_volume) + 2; + let diff = sample as i32 - 32; + let centered = (diff * gain as i32 * 2) / den as i32; + (centered * 7) as i16 + } +``` + +--- + +#### MAP-R2-04: Fix VRC2/VRC4 Submapper 3 Dispatch & Enforce Hardware VRC2 Mirroring + +- **Severity / Category**: High / Hardware Parity & Submapper Compliance +- **Target File & Line Numbers**: `crates/rustynes-mappers/src/lib.rs:629-645` and `crates/rustynes-mappers/src/m022_vrc2.rs:263-270` + +**Observation & Root Cause Analysis**: +Mapper 23 and 25 always instantiate `Vrc4`, even when `h.submapper == 3`. Per NES 2.0 specification, Mapper 23 submapper 3 is VRC2b and Mapper 25 submapper 3 is VRC2c. Instantiating `Vrc4` introduces single-screen mirroring and IRQ registers that corrupt *Wai Wai World*. Furthermore, `Vrc2` decodes bit 1 of `$9000` allowing single-screen mirroring, whereas real VRC2 hardware decodes only bit 0 (Vertical/Horizontal). + +**Research Citations & Specifications**: +NESdev Wiki: "VRC2" and "NES 2.0 Mapper 023 / 025". + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-mappers/src/lib.rs:629-645: + 21 | 23 | 25 => { + if (h.mapper_id == 23 || h.mapper_id == 25) && h.submapper == 3 { + let vrc2 = Vrc2::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?; + Box::new(vrc2) + } else { + let m021_vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring) + .map_err(|e| RomError::InvalidConfig(e.to_string()))?; + Box::new(m021_vrc4) + } + } + +// In crates/rustynes-mappers/src/m022_vrc2.rs:263-270: + 0x9000 => { + self.mirroring = if (value & 0x01) != 0 { + Mirroring::Horizontal + } else { + Mirroring::Vertical + }; + } +``` + +--- + +#### MAP-R2-05: Non-Volatile Save Data Dirty Tracking + +- **Severity / Category**: Medium / SRAM/Flash Persistence Performance +- **Target File & Line Numbers**: `crates/rustynes-mappers/src/mapper.rs:686` and `crates/rustynes-frontend/src/battery_policy.rs:101` + +**Observation & Root Cause Analysis**: +`Mapper` exposes dirty tracking for FDS disks, but lacks dirty tracking for battery-backed SRAM/flash. `BatteryPolicy::differs()` compares the entire live buffer against `self.last` every 60 frames. For GTROM (mapper 111) and UNROM 512 (mapper 30), this compares 512 KiB of memory every second. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-mappers/src/mapper.rs: + fn is_save_data_dirty(&self) -> Option { + None + } + + fn clear_save_data_dirty(&mut self) {} +``` + +--- + +#### MAP-R2-06: Mapper 71 Fire Hawk Single-Screen Mirroring Fallback on iNES 1.0 + +- **Severity / Category**: Medium / Game Compatibility +- **Target File & Line Numbers**: `crates/rustynes-mappers/src/m071_camerica_bf9093.rs:100-108` and `lib.rs:675` + +**Observation & Root Cause Analysis**: +Single-screen mirroring on Mapper 71 is gated on `h.is_nes2 && h.submapper == 1`. In `game_database.txt`, *Fire Hawk (USA)* has submapper 0. On any iNES 1.0 dump of *Fire Hawk*, writes to `$9000-$9FFF` fall through into `$8000-$BFFF`, corrupting `prg_bank` and leaving mirroring unswitched. + +**Research Citations & Specifications**: +NESdev Wiki: "INES Mapper 071" — On non-NES 2.0 carts, any write to `$9000-$9FFF` activates single-screen mirroring control. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-mappers/src/m071_camerica_bf9093.rs:100-108: + match addr { + 0x9000..=0x9FFF => { + self.has_single_screen = true; + self.mirroring = if value & 0x10 == 0 { + Mirroring::SingleScreenA + } else { + Mirroring::SingleScreenB + }; + } + 0xC000..=0xFFFF | 0x8000..=0xBFFF => self.prg_bank = value & 0x0F, + _ => {} + } +``` + +--- + +#### MAP-R2-07: MMC5 Scanline Counter Hardware Specification Parity Documentation + +- **Severity / Category**: Low / Documentation & Hardware Accuracy +- **Target File & Line Numbers**: `crates/rustynes-mappers/src/m005_mmc5.rs:1649-1662` + +**Observation & Root Cause Analysis**: +MMC5 scanline counter is triggered via out-of-band PPU callbacks (`notify_scanline_start()` on dot 0 and `notify_vblank()`). Real MMC5 hardware detects scanlines by snooping 3 consecutive matching nametable reads ($2000-$2FFF) and clocks the IRQ at dot 4, clearing the in-frame bit after 3 idle M2 cycles without PPU reads. Documenting this discrepancy clarifies why MMC5 scanline IRQs trigger on dot 0 instead of dot 4. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-mappers/src/m005_mmc5.rs ++++ b/crates/rustynes-mappers/src/m005_mmc5.rs +@@ -560,2 +560,5 @@ pub struct Mmc5 { + scanline_counter: u8, ++ last_nt_read_addr: u16, ++ consecutive_nt_reads: u8, ++ m2_idle_cycles: u8, + /// True while the PPU is inside the active display frame (between the +@@ -681,2 +684,5 @@ impl Mmc5 { + scanline_counter: 0, ++ last_nt_read_addr: 0xFFFF, ++ consecutive_nt_reads: 0, ++ m2_idle_cycles: 0, + in_frame: false, +@@ -1292,2 +1298,30 @@ impl Mapper for Mmc5 { + fn ppu_read(&mut self, addr: u16) -> u8 { ++ self.m2_idle_cycles = 0; ++ let clean_addr = addr & 0x3FFF; ++ if (0x2000..=0x2FFF).contains(&clean_addr) { ++ if self.last_nt_read_addr == clean_addr { ++ self.consecutive_nt_reads = self.consecutive_nt_reads.saturating_add(1); ++ if self.consecutive_nt_reads == 3 { ++ if !self.in_frame { ++ self.in_frame = true; ++ self.scanline_counter = 0; ++ } else { ++ self.scanline_counter = self.scanline_counter.wrapping_add(1); ++ } ++ if self.scanline_counter == self.irq_compare && self.irq_compare != 0 { ++ self.irq_pending = true; ++ } ++ } ++ } else { ++ self.last_nt_read_addr = clean_addr; ++ self.consecutive_nt_reads = 1; ++ } ++ } else { ++ self.consecutive_nt_reads = 0; ++ self.last_nt_read_addr = 0xFFFF; ++ } + let addr = addr & 0x3FFF; +@@ -1576,2 +1610,6 @@ impl Mapper for Mmc5 { + fn notify_cpu_cycle(&mut self) { ++ self.m2_idle_cycles = self.m2_idle_cycles.saturating_add(1); ++ if self.m2_idle_cycles >= 3 { ++ self.in_frame = false; ++ } + // No CPU-cycle IRQ for MMC5. However, the audio extension's two +@@ -1683,2 +1721,5 @@ impl Mapper for Mmc5 { + self.in_frame = false; ++ self.consecutive_nt_reads = 0; ++ self.last_nt_read_addr = 0xFFFF; ++ self.m2_idle_cycles = 0; + } +``` + +--- + +### Section 5: Netplay, Rollback & State Serialization + +#### NET-R2-01: Checkpoint Restitution and Re-simulation Storm on Every Confirmed Frame + +- **Severity / Category**: **Critical** | Netplay Performance & Rollback Overhead +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/session.rs:559-565, 757-811` + +**Observation & Root Cause Analysis**: +In Round 1, `self.snapshots` was removed to fix NET-01's memory leak. However, in `session.rs:562`: + +```rust +if (mispredicted || confirmation_advanced) && self.checkpoint.is_some() { + resimulated = self.resync(nes)?; +} +``` + +`self.resync(nes)` is invoked whenever `confirmation_advanced == true`, even when predictions were 100% correct (`mispredicted == false`). In steady-state play, confirmation advances on virtually every tick. The engine restores the checkpoint and re-simulates up to 8 frames, re-serializing the entire core state on every tick. This multiplies per-frame emulation workload by 2x to 9x during normal gameplay with zero prediction errors. + +Crucially, attempting to fix this by taking a snapshot of `nes` when `confirmation_advanced` is true while running ahead (`self.current_frame > confirmed_boundary + 1`) introduces a catastrophic desync hazard: `nes` is executing with speculative predicted inputs. Serializing `nes` at `current_frame` and mislabeling it as `confirmed_boundary + 1` contaminates the canonical rollback checkpoint with speculative state. On any future rollback to `confirmed_boundary + 1`, predicted state is restored instead of canonical history, causing irreversible session desynchronization. + +**Research Citations & Specifications**: +GGPO Rollback Networking Architecture: State resimulation must only occur when remote inputs diverge from local predictions (`mispredicted == true`). Pure confirmation advances the confirmed boundary without re-simulating the current frame. Canonical checkpoints must only be captured from unspeculative execution. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-netplay/src/session.rs:559-575: + let mispredicted = earliest_mispredict.is_some_and(|m| m < self.current_frame); + let mut rolled_back = false; + let mut resimulated = 0u32; + if mispredicted && self.checkpoint.is_some() { + // Divergence detected: re-simulate canonical frames from confirmed checkpoint forward + resimulated = self.resync(nes)?; + rolled_back = true; + } else if confirmation_advanced && self.checkpoint.is_some() { + let confirmed_boundary = self.last_confirmed_frame.unwrap_or(0); + // Safe advancement: only snapshot directly if running with zero speculation delay + // (i.e. nes is executing strictly on confirmed inputs). + if self.current_frame <= confirmed_boundary + 1 { + if let Some((cp_frame, _)) = &self.checkpoint { + if *cp_frame <= confirmed_boundary { + let mut cp_buf = self.checkpoint.take().map_or_else(Vec::new, |(_, v)| v); + nes.snapshot_core_into_slim(&mut cp_buf); + self.checkpoint = Some((self.current_frame, cp_buf)); + } + } + } + // When running ahead with speculative inputs (current_frame > confirmed_boundary + 1), + // do NOT snapshot the active speculative nes instance. The existing checkpoint remains + // valid at cp_frame until a real misprediction occurs, or can be advanced via a + // bounded ring buffer of pre-speculative slim checkpoints (VecDeque<(u32, Vec)> + // bounded to MAX_ROLLBACK_WINDOW). + } +``` + +--- + +#### NET-R2-02: Full 245 KB Framebuffer Serialization in Netplay Rollback Snapshots + +- **Severity / Category**: High / Memory Bandwidth +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/session.rs:598, 807` vs `crates/rustynes-core/src/nes.rs:2404-2428` + +**Observation & Root Cause Analysis**: +`RollbackSession` calls `nes.snapshot_core_into(&mut seed)`, serializing the full 245,760-byte framebuffer on every netplay checkpoint. Rollback re-simulation never displays intermediate framebuffers restored from checkpoints—it immediately re-executes `apply_and_run(nes, f)`, which overwrites the active framebuffer. Serializing 245 KB instead of using `snapshot_core_into_slim()` (~4 KB) wastes 98.4% of snapshot memory bandwidth. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/session.rs ++++ b/crates/rustynes-netplay/src/session.rs +@@ -595,7 +595,7 @@ impl RollbackSession { + if self.checkpoint.is_none() { + let mut seed = Vec::new(); +- nes.snapshot_core_into(&mut seed); ++ nes.snapshot_core_into_slim(&mut seed); + self.checkpoint = Some((frame, seed)); + } +@@ -806,7 +806,7 @@ impl RollbackSession { + let mut cp_buf = self.checkpoint.take().map_or_else(Vec::new, |(_, v)| v); +- nes.snapshot_core_into(&mut cp_buf); ++ nes.snapshot_core_into_slim(&mut cp_buf); + self.checkpoint = Some((f + 1, cp_buf)); +``` + +--- + +#### NET-R2-03: UDP Packet Explosion in Input Resends Emitting up to 64 Datagrams Per Frame + +- **Severity / Category**: High / Network Protocol Efficiency +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/session.rs:958-982` and `crates/rustynes-netplay/src/connection.rs:307-326` + +**Observation & Root Cause Analysis**: +`resend_unacked_local_inputs()` loops over unacknowledged frames (`INPUT_RESEND_WINDOW = 64`) and calls `transport.send(&NetMessage::Input)` on each frame individually. Each call issues an immediate `send_to` syscall emitting a 35-byte UDP datagram (28-byte IP/UDP headers + 7-byte payload). At 60 Hz, an unacknowledged window of 20-64 frames generates 1,200 to 3,840 individual UDP packets per second, causing severe packet congestion and socket drops. + +**Research Citations & Specifications**: +RFC 8085 ("UDP Usage Guidelines"): Applications transmitting high-frequency updates must aggregate unacknowledged states into combined datagram payloads to minimize header overhead and packet burst drops. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/message.rs ++++ b/crates/rustynes-netplay/src/message.rs +@@ -262,2 +262,8 @@ pub enum NetMessage { ++ /// Batched sequence of consecutive inputs for `player` starting at `start_frame`. ++ InputBatch { ++ player: u8, ++ start_frame: u32, ++ inputs: Vec, ++ }, ++ + /// Acknowledges receipt of the remote's input up to `frame`. +@@ -369,2 +375,3 @@ impl NetMessage { + const TAG_ROSTER: u8 = 5; ++ const TAG_INPUT_BATCH: u8 = 6; + +@@ -402,2 +409,13 @@ impl NetMessage { ++ Self::InputBatch { ++ player, ++ start_frame, ++ ref inputs, ++ } => { ++ out.push(Self::TAG_INPUT_BATCH); ++ out.push(player); ++ out.extend_from_slice(&start_frame.to_le_bytes()); ++ out.push(u8::try_from(inputs.len().min(u8::MAX as usize)).unwrap_or(u8::MAX)); ++ out.extend_from_slice(inputs); ++ } + Self::InputAck { frame } => { +@@ -496,2 +514,14 @@ impl NetMessage { + } ++ Self::TAG_INPUT_BATCH => { ++ let player = *rest.first()?; ++ let start_frame = u32::from_le_bytes(rest.get(1..5)?.try_into().ok()?); ++ let count = *rest.get(5)? as usize; ++ let inputs_slice = rest.get(6..6 + count)?; ++ Some(Self::InputBatch { ++ player, ++ start_frame, ++ inputs: inputs_slice.to_vec(), ++ }) ++ } + Self::TAG_INPUT_ACK => { +--- a/crates/rustynes-netplay/src/session.rs ++++ b/crates/rustynes-netplay/src/session.rs +@@ -703,2 +703,13 @@ impl RollbackSession { ++ NetMessage::InputBatch { ++ player, ++ start_frame, ++ inputs, ++ } => { ++ for (offset, input) in inputs.into_iter().enumerate() { ++ let frame = start_frame.saturating_add(offset as u32); ++ if let Some(mispredict) = self.ingest_single_input(player, frame, input) { ++ earliest_mispredict = match earliest_mispredict { ++ Some(e) => Some(e.min(mispredict)), ++ None => Some(mispredict), ++ }; ++ } ++ } ++ } +@@ -966,16 +977,20 @@ impl RollbackSession { + let start = first_unacked.max(newest.saturating_sub(INPUT_RESEND_WINDOW)); ++ if start > newest { ++ return; ++ } ++ let mut batched = Vec::with_capacity((newest - start + 1) as usize); + for frame in start..=newest { + let idx = frame as usize; + if idx >= self.history.len() { + break; + } + let slot = self.history[idx].players[lp_idx]; +- // Only resend frames we actually authored (confirmed-local). + if slot.confirmed { +- self.transport.send(&NetMessage::Input { +- player: lp, +- frame, +- input: slot.input, +- }); ++ batched.push(slot.input); ++ } else { ++ break; + } + } ++ if !batched.is_empty() { ++ self.transport.send(&NetMessage::InputBatch { ++ player: lp, ++ start_frame: start, ++ inputs: batched, ++ }); ++ } + } +``` + +--- + +#### NET-R2-04: Premature Socket Consumption in `MeshHost::pump` Dropping Roster Resends + +- **Severity / Category**: High / Network Reliability +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/mesh_net.rs:388-419` + +**Observation & Root Cause Analysis**: +`MeshHost` defines `const ROSTER_RESENDS: u8 = 5;` to ensure joiners receive the roster over lossy UDP. However, immediately after sending the *first* roster packet (`roster_resends` moves from 5 to 4), `pump()` unconditionally executes `self.socket.take()` and returns `UdpMeshTransport`. The remaining 4 scheduled resends are never sent. If a joiner drops the single initial roster packet, it hangs until the 30-second timeout expires. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/mesh_net.rs ++++ b/crates/rustynes-netplay/src/mesh_net.rs +@@ -404,6 +404,9 @@ impl MeshHost { + self.last_roster_sent = Some(now); + self.roster_resends -= 1; + } ++ if self.roster_resends > 0 { ++ return Ok(None); ++ } + let socket = self.socket.take().expect("host socket present to hand off"); + let peers: Vec = self.joiners.keys().copied().collect(); +``` + +--- + +#### NET-R2-05: Inefficient Byte-by-Byte Scalar FNV-1a Hashing on 245 KB Framebuffers + +- **Severity / Category**: Medium / Hashing Performance +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/message.rs:588-603` and `crates/rustynes-netplay/src/session.rs:830-836` + +**Observation & Root Cause Analysis**: +`gameplay_digest_parts()` hashes the 245,760-byte framebuffer on confirmed frames using `fnv1a64`. `fnv1a64` processes one byte at a time with a 64-bit scalar multiplication per byte, yielding throughput < 1 GB/s (~0.25 ms per call). Chunking into 64-bit words improves hashing throughput by 4x to 8x. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/message.rs ++++ b/crates/rustynes-netplay/src/message.rs +@@ -594,10 +594,18 @@ impl NetMessage { + pub fn fnv1a64(data: &[u8]) -> u64 { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = OFFSET; +- for &b in data { +- hash ^= u64::from(b); ++ let mut chunks = data.chunks_exact(8); ++ for chunk in &mut chunks { ++ let word = u64::from_le_bytes(chunk.try_into().unwrap()); ++ hash ^= word; ++ hash = hash.wrapping_mul(PRIME); ++ } ++ for &b in chunks.remainder() { ++ hash ^= u64::from(b); + hash = hash.wrapping_mul(PRIME); + } + hash + } +``` + +--- + +#### NET-R2-06: Netplay Thread Inversion — Disabling Emulation Thread and Executing on UI Thread + +- **Severity / Category**: High / Concurrency Architecture +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/emu_thread.rs:258-264, 704-715` and `crates/rustynes-frontend/src/app.rs:7540-7607` + +**Observation & Root Cause Analysis**: +When netplay starts, `set_netplay_paused(true)` pauses `EmuThread`. Netplay is driven on the winit UI thread inside `produce_one_frame_netplay()`. The UI thread synchronously performs UDP socket draining, multi-frame rollback re-simulations, snapshot serializations, and framebuffer hashing while holding `Mutex`. Any network jitter or rollback spike directly blocks the UI thread for 10-25 ms, causing input latency and egui interface stutters. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-frontend/src/emu_thread.rs ++++ b/crates/rustynes-frontend/src/emu_thread.rs +@@ -703,3 +703,2 @@ fn run( + let idle = !control.has_rom.load(Ordering::Acquire) +- || control.netplay_paused.load(Ordering::Acquire) + || control.user_paused.load(Ordering::Acquire); +--- a/crates/rustynes-frontend/src/app.rs ++++ b/crates/rustynes-frontend/src/app.rs +@@ -7539,4 +7539,10 @@ impl App { + fn produce_one_frame_netplay(&mut self, raw_local: Buttons, turbo_mask: u8, turbo_period: u8) { +- let mut guard = self.emu.lock(); ++ let Some(mut guard) = self.emu.try_lock() else { ++ // Background EmuThread is actively simulating or rolling back; ++ // present previous frame rather than blocking the UI thread. ++ return; ++ }; + let emu = &mut *guard; + let Some(nes) = emu.nes.as_mut() else { + return; +``` + +--- + +#### NET-R2-07: Unauthenticated UDP Packet Ingestion Permitting Third-Party Desync Termination + +- **Severity / Category**: High | Denial of Service & Packet Spoofing +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/connection.rs:328-333`, `mesh_net.rs:138-156`, and `session.rs:638-740` + +**Observation & Root Cause Analysis**: +In `UdpTransport::poll()` and `UdpMeshTransport::poll()`, the source `SocketAddr` is discarded. `RollbackSession::ingest()` processes all messages without source address validation. An off-path attacker sending an unauthenticated UDP packet containing `NetMessage::Checksum { frame, hash: 0, fb_hash: 0 }` to an open player port triggers a fatal `NetplayError::Desync`, immediately terminating the game session. + +**Research Citations & Specifications**: +CWE-345 ("Insufficient Verification of Data Authenticity"): Network endpoints must validate that incoming datagrams originate from authenticated peer addresses before applying state mutations or consistency checks. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/connection.rs ++++ b/crates/rustynes-netplay/src/connection.rs +@@ -328,6 +328,7 @@ impl Transport for UdpTransport { + fn poll(&mut self) -> Vec { + self.poll_with_source() + .into_iter() ++ .filter(|(_msg, from)| self.remote.is_none_or(|peer| peer == *from)) + .map(|(msg, _from)| msg) + .collect() + } + +--- a/crates/rustynes-netplay/src/mesh_net.rs ++++ b/crates/rustynes-netplay/src/mesh_net.rs +@@ -141,7 +141,12 @@ impl Transport for UdpMeshTransport { + for _ in 0..MAX_DATAGRAMS_PER_POLL { +- match self.socket.recv_from(&mut buf) { +- Ok((len, _from)) => match NetMessage::from_bytes(&buf[..len]) { +- Some(msg) => out.push(msg), ++ match self.socket.recv_from(&mut buf) { ++ Ok((len, from)) => { ++ if !self.peers.contains(&from) { ++ continue; ++ } ++ match NetMessage::from_bytes(&buf[..len]) { ++ Some(msg) => out.push(msg), +``` + +--- + +#### NET-R2-08: Zombie Stalls & Dead Liveness Detection Caused by `NetplayConnection` Discard + +- **Severity / Category**: High / Error Handling & Session Recovery +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/netplay_ui.rs:421-424, 464-520` and `crates/rustynes-netplay/src/connection.rs:379-418` + +**Observation & Root Cause Analysis**: +`NetplayConnection` implements peer liveness monitoring (`PeerLink::TimedOut`). However, upon reaching `ConnectionState::Synced`, `conn.into_transport()` is called, deallocating the `NetplayConnection` instance. In `InGame`, only `RollbackSession` runs. `RollbackSession` has no peer disconnect timeout; if a remote peer crashes or drops offline, `should_stall()` triggers and returns `AdvanceOutcome::STALLED` indefinitely. The frontend freezes forever without notifying the user or terminating the session. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-frontend/src/netplay_ui.rs ++++ b/crates/rustynes-frontend/src/netplay_ui.rs +@@ -72,2 +72,4 @@ pub struct NetplayUi { + identity: SessionIdentity, ++ last_peer_activity: std::time::Instant, ++ last_confirmed_tracker: u32, + status: NetplayStatus, +@@ -109,2 +111,4 @@ impl NetplayUi { + identity: SessionIdentity::new([0; 32], [0; 32]), ++ last_peer_activity: std::time::Instant::now(), ++ last_confirmed_tracker: 0, + status: NetplayStatus::default(), +@@ -424,2 +428,4 @@ impl NetplayUi { + self.state = NetplayState::InGame(Box::new(session)); ++ self.last_peer_activity = std::time::Instant::now(); ++ self.last_confirmed_tracker = 0; + self.status.phase = NetplayPhase::InGame; +@@ -470,2 +476,7 @@ impl NetplayUi { + session.add_local_input(local_buttons); ++ let confirmed = session.last_confirmed_frame(); ++ if confirmed > self.last_confirmed_tracker { ++ self.last_confirmed_tracker = confirmed; ++ self.last_peer_activity = std::time::Instant::now(); ++ } + match session.advance(nes) { +@@ -476,2 +487,10 @@ impl NetplayUi { + }) => { ++ if !produced_frame && self.last_peer_activity.elapsed() > std::time::Duration::from_secs(5) { ++ self.fail("peer connection lost (no data for several seconds)".to_string()); ++ return NetplayTick { ++ active: false, ++ produced_frame: false, ++ }; ++ } + self.status.phase = NetplayPhase::InGame; +``` + +--- + +#### NET-R2-09: Unbounded Monotonic History Vector Growth in `RollbackSession` + +- **Severity / Category**: Medium / Memory Growth & Cache Footprint +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/session.rs:265, 269, 281, 286, 302, 501-516` + +**Observation & Root Cause Analysis**: +While NET-01 fixed the leak in `self.snapshots`, the remaining five per-frame vectors (`history`, `local_checksums`, `confirmed_hashes`, `remote_checksums`, `confirmed_entering`) continue to append and reallocate indefinitely. For comparison, `SpectatorSession` in `spectator.rs` was migrated in v3.0.0 to a sliding-window `VecDeque`. In `RollbackSession`, entries older than `last_confirmed_frame` are never read again, yet they remain allocated, causing ~21.6 MB/hr of memory growth. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/session.rs ++++ b/crates/rustynes-netplay/src/session.rs +@@ -303,2 +303,4 @@ pub struct RollbackSession { + confirmed_entering: Vec>, ++ base_frame_offset: usize, + +@@ -534,2 +536,15 @@ impl RollbackSession { + let confirmation_advanced = self.last_confirmed_frame != confirmed_before; ++ if confirmation_advanced { ++ const RETAIN_WINDOW: usize = 64; ++ let prune_target = (self.last_confirmed_frame as usize).saturating_sub(RETAIN_WINDOW); ++ if prune_target > self.base_frame_offset { ++ let drain_count = prune_target - self.base_frame_offset; ++ self.history.drain(..drain_count); ++ self.local_checksums.drain(..drain_count); ++ self.confirmed_hashes.drain(..drain_count); ++ self.remote_checksums.drain(..drain_count); ++ self.confirmed_entering.drain(..drain_count); ++ self.base_frame_offset = prune_target; ++ } ++ } +``` + +--- + +### Section 6: Mobile (Android/iOS) & Libretro Core + +#### MOB-R2-01: UniFFI Framebuffer FFI Allocation Storm (14.75 MB/sec Heap Churn) + +- **Severity / Category**: High / Mobile Allocation & GC Overhead +- **Target File & Line Numbers**: +- `crates/rustynes-mobile/src/lib.rs:1368-1376` +- `android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt:2874-2900` +- `ios/RustyNES/RustyNES/EmulatorCore.swift:256, 340-344` + +**Observation & Root Cause Analysis**: +`run_frame()` executes `g.nes.framebuffer().to_vec()`, allocating a fresh 245,760-byte `Vec` every frame (60 FPS = 14.75 MB/sec). UniFFI serializes it into `RustBuffer` and allocates a fresh 245,760-byte array on the managed heap (`byte[]` in Kotlin, `Data` in Swift). On Android, `submitFrame(fb)` passes this array across JNI, where `get_region` copies it back into `gfx.frame_buf`. On iOS, Swift calls `rustynes_ios_gfx_render` where `dst.copy_from_slice(src)` copies it back into `MetalGfx.frame_buf`. This triggers continuous Garbage Collection sweeps and thermal throttling on mobile devices. + +**Research Citations & Specifications**: +Mozilla UniFFI Best Practices: High-frequency frame rendering should avoid owned `Vec` returns across foreign language bridges; pre-allocated host buffers or direct C-ABI pointers must be used to eliminate heap allocations. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-mobile/src/lib.rs: +impl NesController { + /// Borrow the RGBA8 framebuffer directly under the inner lock without heap allocation. + pub fn with_framebuffer(&self, f: impl FnOnce(&[u8]) -> R) -> R { + let g = self.lock(); + f(g.nes.framebuffer()) + } + + /// Copy the current RGBA8 framebuffer directly into a pre-allocated host slice. + pub fn copy_framebuffer_into(&self, dst: &mut [u8]) -> bool { + let g = self.lock(); + let fb = g.nes.framebuffer(); + if dst.len() != fb.len() { + return false; + } + dst.copy_from_slice(fb); + true + } +} +``` + +--- + +#### AND-R2-01: Android AudioTrack Latency, Blocking Stalls & Lack of Rate Control + +- **Severity / Category**: High / Audio Synchronization & Frame Pacing +- **Target File & Line Numbers**: `android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt:970-1058, 2905-2910` + +**Observation & Root Cause Analysis**: +`AudioPlayer` calls `track.write(byteBuffer, size, AudioTrack.WRITE_BLOCKING)` on the main coroutine emulation thread (`Dispatchers.Default`). Because the NTSC NES clock produces ~60.0988 fps, sample generation rate (~798.7 samples/frame at 48kHz) differs from DAC hardware timing. Without Dynamic Rate Control or an elastic ring buffer, clock drift causes `track.write` to block the emulation loop when full (delaying frame presentation and causing visual judder) or underrun when starved (audio crackles). In addition, `ByteBuffer.wrap(bytes)` allocates a new heap wrapper object every frame. + +**Research Citations & Specifications**: +Google Android High-Performance Audio Guidelines: Real-time audio writing from emulation threads must use non-blocking modes (`WRITE_NON_BLOCKING`) coupled with dynamic rate control to absorb clock drift without blocking frame presentation. + +**Concrete Drop-In Patch**: + +```kotlin +// In android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt: +fun writeBytes(bytes: ByteArray) { + if (focusMuted || bytes.isEmpty()) return + val written = track.write(bytes, 0, bytes.size, AudioTrack.WRITE_NON_BLOCKING) + if (written < 0) { + // Handle underrun/error logging + } +} +``` + +--- + +#### IOS-R2-01: iOS Metal Redundant Intermediate Staging Buffer & Copy + +- **Severity / Category**: High / GPU Memory Bandwidth +- **Target File & Line Numbers**: `crates/rustynes-ios/src/gfx_metal.rs:98, 534-538, 577-595` and `crates/rustynes-ios/src/ffi.rs:93-110` + +**Observation & Root Cause Analysis**: +`dst.copy_from_slice(src)` copies 245,760 bytes into `self.frame_buf`, and then `gfx.render()` immediately passes `&self.frame_buf` to `queue.write_texture()`, which copies it into wgpu's internal staging buffer. In contrast, `set_index_frame` directly takes `idx_bytes: &[u8]` and passes it to `queue.write_texture()`. Maintaining `frame_buf` and doing an intermediate copy on Apple Silicon unified memory is redundant. + +**Research Citations & Specifications**: +Apple Metal Best Practices ("Optimizing Texture Uploads on Unified Memory"): Avoid redundant CPU-to-CPU memcpy operations into temporary buffers prior to issuing texture uploads. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-ios/src/gfx_metal.rs: +// Remove `frame_buf: Vec` from MetalGfx and update `render`: +pub fn render(&mut self, fb: &[u8]) { + self.write_uniforms(); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.nes_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + fb, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(NES_W * 4), + rows_per_image: Some(NES_H), + }, + wgpu::Extent3d { + width: NES_W, + height: NES_H, + depth_or_array_layers: 1, + }, + ); +} + +// In crates/rustynes-ios/src/ffi.rs: +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rustynes_ios_gfx_render(handle: *mut MetalGfx, fb: *const u8, len: usize) { + guarded("rustynes_ios_gfx_render", (), || { + if handle.is_null() || fb.is_null() { + return; + } + let gfx = unsafe { &mut *handle }; + if len != (NES_W * NES_H * 4) as usize { + return; + } + let src = unsafe { core::slice::from_raw_parts(fb, len) }; + gfx.render(src); + }) +} +``` + +--- + +#### LIB-R2-01: Libretro Video Buffer Two-Pass Transcoding Overhead + +- **Severity / Category**: Medium / Frame Conversion Throughput +- **Target File & Line Numbers**: `crates/rustynes-libretro/src/lib.rs:1699-1707` + +**Observation & Root Cause Analysis**: +`run_single` clears `video_buffer`, performs an unswapped memory copy of 245,760 bytes from `nes.framebuffer()` into `video_buffer`, and then immediately iterates through all 61,440 pixels executing `chunk.swap(0, 2)`. This traverses 245,760 bytes twice in memory (720 KiB total bandwidth per frame; 43.2 MB/sec at 60 FPS). + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-libretro/src/lib.rs: +#[inline] +fn blit_rgba_to_xrgb(dst: &mut [u8], src: &[u8]) { + for (d, s) in dst.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + d[0] = s[2]; // B + d[1] = s[1]; // G + d[2] = s[0]; // R + d[3] = s[3]; // X (alpha) + } +} + +// In run_single(): +const EXPECTED_LEN: usize = NES_W * NES_H * 4; +if self.video_buffer.len() != EXPECTED_LEN { + self.video_buffer.resize(EXPECTED_LEN, 0); +} +let Some(nes) = self.nes.as_ref() else { return; }; +blit_rgba_to_xrgb(&mut self.video_buffer, nes.framebuffer()); +ctx.draw_frame(&self.video_buffer, NES_W as u32, NES_H as u32, NES_W * 4); +``` + +--- + +#### MOB-R2-02: Missing Keep-Screen-On / Idle Timer Handling on Android and iOS + +- **Severity / Category**: Medium / Mobile UX & Power Lifecycle +- **Target File & Line Numbers**: +- `android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt:294-308` +- `ios/RustyNES/RustyNES/AppModel.swift:1072-1078` + +**Observation & Root Cause Analysis**: +Neither Android `MainActivity` nor iOS `AppModel` manages screen wake locks (`FLAG_KEEP_SCREEN_ON` on Android, `UIApplication.shared.isIdleTimerDisabled` on iOS). When players use external Bluetooth/MFi gamepads or watch TAS/replay movies without touching the screen, the system display timeout turns off the screen after 30-60 seconds. + +**Concrete Drop-In Patch**: + +```kotlin +// In Android MainActivity.kt: +fun updateScreenWakeLock(running: Boolean) { + runOnUiThread { + if (running) { + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } +} + +// In iOS AppModel.swift: +private func applyRunState() { + let isRunning = sceneActive && !menuPaused && !audioInterrupted && !routePaused + UIApplication.shared.isIdleTimerDisabled = isRunning + if isRunning { + emulator?.resume() + } else { + emulator?.pause() + } +} +``` + +--- + +#### AND-R2-02: Memory Leak of Destroyed Surface in `NesSurfaceView` + +- **Severity / Category**: Medium / Android Memory Management +- **Target File & Line Numbers**: `android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt:144-160` + +**Observation & Root Cause Analysis**: +`pendingSurface` retains the `android.view.Surface` reference across `surfaceDestroyed`. It is never reset to `null`, preventing garbage collection of the destroyed Surface and native window handles. + +**Concrete Drop-In Patch**: + +```kotlin +// In android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt:144-160: +override fun surfaceDestroyed(holder: SurfaceHolder) { + val w: Worker? + synchronized(lock) { + surfaceGone = true + pendingSurface = null + w = worker + w?.running?.set(false) + } + w?.thread?.let { + LockSupport.unpark(it) + runCatching { it.join(800) } + } +} +``` + +--- + +#### MOB-R2-03: Redundant Allocations in HD-Pack Composite Loop + +- **Severity / Category**: Low / Mobile Allocation Overhead +- **Target File & Line Numbers**: `crates/rustynes-mobile/src/lib.rs:3025-3031` + +**Observation & Root Cause Analysis**: +`composite_hd_locked` allocates a 245,760-byte `framebuffer` vector, a 61,440-element `hd_tiles` vector, and an 8,192-byte `chr` vector every frame on the heap, only to pass references to `comp.composite()`. Borrowing slices directly avoids cloning data that is already resident in memory. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-mobile/src/lib.rs:3025-3031: +fn composite_hd_locked(g: &mut Inner) -> Vec { + if g.hd_pack.is_none() { + return Vec::new(); + } + let hd_tiles = g.nes.hd_tile_source(); + let framebuffer = g.nes.framebuffer(); + let mut chr = [0u8; 0x2000]; + for (addr, slot) in (0u16..0x2000).zip(chr.iter_mut()) { + *slot = g.nes.peek_ppu(addr); + } +``` + +--- + +#### LIB-R2-02: Libretro Core Options Completeness & Scalar Audio Push Loop + +- **Severity / Category**: Low / Feature Parity & Audio Processing Loop +- **Target File & Line Numbers**: `crates/rustynes-libretro/src/lib.rs:358-370, 1607-1620` + +**Observation & Root Cause Analysis**: +Only `rustynes_four_score` is declared in `CORE_VARIABLES`. Emulation core options introduced in v3.1.0 (CPU overclocking, sprite limit disable / flicker removal) are inaccessible in RetroArch. In addition, `push_audio` performs individual bounds-checked `Vec::push` calls up to 1,600 times per frame instead of slice-writing. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-libretro/src/lib.rs: +fn push_audio(&mut self, ctx: &mut RunContext, produced: usize) { + let needed = produced * 2; + if self.audio_buffer.len() != needed { + self.audio_buffer.resize(needed, 0); + } + for (&sample, chunk) in self.audio_float_buffer[..produced] + .iter() + .zip(self.audio_buffer.chunks_exact_mut(2)) + { + let s16 = sample_to_i16(sample); + chunk[0] = s16; + chunk[1] = s16; + } + rust_libretro::contexts::AudioContext::from(&mut *ctx) + .batch_audio_samples(&self.audio_buffer); +} +``` + +--- + +### Section 7: MiSTer RTL & Hardware Architecture + +#### RTL-R2-01: 32-Bit Signed Accumulator Overflow in Audio DC-Blocker Causing Full-Scale Inversion and Loud Audio Popping + +- **Severity / Category**: **Critical** | Audio DSP & Arithmetic Overflow +- **Target File & Line Numbers**: `rtl/audio_dc_block.sv:94-126` + +**Observation & Root Cause Analysis**: +In `audio_dc_block.sv`: + +```systemverilog +localparam int W = 32; +logic signed [W-1:0] x_q; +always_comb x_q = $signed({{(W-16){1'b0}}, mix}) <<< FRAC; // FRAC = 15 +``` + +Maximum 2A03 mixer output is `mix = 16'hFFFE = 65,534`. `x_q = 65534 <<< 15 = 2,147,418,112` (`0x7FFF_0000`). +In signed 32-bit two's complement, `INT32_MAX = 2,147,483,647`. The headroom between `x_q` and `INT32_MAX` is only `65,535` LSBs. +At line 103: `y_next = x_q - x_prev + y - (y >>> SHIFT);`. +When a loud sound begins (`x_prev == 0`) and the filter state `y > 65,535` (note that `SAT_HI = 32,767 <<< 15 = 1,073,709,056`), `x_q + y` overflows `2,147,483,647` into negative values (`0x80000000`). +When `y_next` overflows to negative (e.g. `-2,147,418,112`), lines 121 & 123 evaluate: + +```systemverilog +sample <= (y_next > SAT_HI) ? 16'sh7FFF : + (y_next < SAT_LO) ? 16'sh8000 : y_next[FRAC+15:FRAC]; +``` + +`SAT_LO = -32768 <<< 15 = -1,073,741,824`. Because `-2,147,418,112 < -1,073,741,824`, `(y_next < SAT_LO)` evaluates to **TRUE**. +Instead of clamping cleanly to positive full-scale `+32767`, the output abruptly flips sign to `-32768` (`16'sh8000`), creating an explosive 65,535-LSB transient step discontinuity (an audible pop/click). + +**Research Citations & Specifications**: +IEEE 1800-2017 SystemVerilog LRM §11.8.1 ("Rules for expression bit lengths"): Signed arithmetic wrap-around occurs when the destination bit width matches the operand width without guard bits. Fixed-point IIR/DC-blocking high-pass filters require at least 8 guard bits to prevent overflow during transient step responses. + +**Concrete Drop-In Patch**: + +```diff +--- a/rtl/audio_dc_block.sv ++++ b/rtl/audio_dc_block.sv +@@ -94,10 +94,10 @@ +- localparam int W = 32; ++ localparam int W = 40; + + logic signed [W-1:0] x_q; // current input, scaled by 2^FRAC + logic signed [W-1:0] x_prev; + logic signed [W-1:0] y; + + always_comb x_q = $signed({{(W-16){1'b0}}, mix}) <<< FRAC; +@@ -105,4 +105,4 @@ +- localparam logic signed [W-1:0] SAT_HI = 32767 <<< FRAC; +- localparam logic signed [W-1:0] SAT_LO = -32768 <<< FRAC; ++ localparam logic signed [W-1:0] SAT_HI = 40'sh00_0000_7FFF <<< FRAC; ++ localparam logic signed [W-1:0] SAT_LO = -40'sh00_0000_8000 <<< FRAC; +``` + +--- + +#### RTL-R2-02: 2 KiB Nametable VRAM Infers 16,384 Dedicated Flip-Flops in LABCELLs Instead of M10K BRAM + +- **Severity / Category**: High / FPGA Area & Logic Utilization +- **Target File & Line Numbers**: `rtl/ppu2c02.sv:236, 2333, 2347, 2968, 3184` + +**Observation & Root Cause Analysis**: +In `output_files/RustyNES.fit.rpt`, `ppu2c02:ppu2c02_inst|vram` synthesizes as **16,384 dedicated flip-flops** across LABCELLs (`vram[0][0]` through `vram[2047][7]`). Total core register utilization is 35,661 registers. `vram` alone accounts for **45.94% of all flip-flops in the entire FPGA design**! Zero M10K BRAM blocks are inferred because asynchronous read access points and combinational indexing expressions are mixed across CPU and PPU pipelines in a monolithic process. + +**Research Citations & Specifications**: +*Intel Quartus Prime Pro Edition User Guide: Design Compilation*, Section "Inferring RAM Functions from Verilog HDL": Synchronous dual-port RAM requires registered read addresses and isolated port interfaces to infer M10K blocks. + +**Concrete Drop-In Patch**: +Extract `vram` into a dedicated synchronous dual-port RAM module: + +```systemverilog +module ppu_vram ( + input logic clk, + // Port A: PPU Scanline rendering fetch + input logic [10:0] addr_a, + output logic [7:0] q_a, + // Port B: CPU $2007 read/write access + input logic [10:0] addr_b, + input logic [7:0] data_b, + input logic we_b, + output logic [7:0] q_b +); + logic [7:0] mem [0:2047]; + + always_ff @(posedge clk) begin + q_a <= mem[addr_a]; + end + + always_ff @(posedge clk) begin + if (we_b) + mem[addr_b] <= data_b; + q_b <= mem[addr_b]; + end +endmodule +``` + +Synthesizing this module maps `vram` into **2 M10K BRAM blocks** and immediately recovers **16,384 flip-flops**, reducing design register utilization by 46%. + +--- + +#### RTL-R2-03: Runtime Modulo Infers 3 `lpm_divide` Megafunctions with 24 ns Critical Delay + +- **Severity / Category**: High / Timing Closure & Combinational Critical Path +- **Target File & Line Numbers**: `rtl/cart/cart.sv:696-699, 715-716, 778-784` + +**Observation & Root Cause Analysis**: +In `cart.sv`: + +```systemverilog +prg_bank_sel = 9'(prg_slot[cpu_addr[14:13]] % prg_8k_count); +chr_bank_sel = 10'(chr_slot[chr_addr[12:10]] % chr_1k_count); +chr_wr_bank_sel = 10'(chr_slot[chr_wr_addr[12:10]] % chr_1k_count); +``` + +In `RustyNES.map.rpt`, lines 1251-1254 confirm the inference of 3 integer dividers (`lpm_divide:Mod0`, `Mod1`, `Mod2`). In `cart.sv:781-784`, the comments note: "Asking it to settle in 11.64 ns fails by 24 ns, on thousands of paths". Because NES bank counts are configured at ROM load, runtime division is unnecessary for power-of-two banks. However, a naive `bank_count - 1` mask corrupts cartridges with odd or non-power-of-2 ROM bank counts (e.g., 3 banks for a 24 KB PRG-ROM image: `3 - 1 = 2` (`0b010`), masking bit 0 to 0 and preventing access to Bank 1). +Using `(1 << $clog2(prg_8k_count)) - 1` along with explicit power-of-2 gating ensures that power-of-2 banks bypass dividers entirely while preserving correct mapping for non-power-of-2 cartridges. + +**Concrete Drop-In Patch**: + +```diff +--- a/rtl/cart/cart.sv ++++ b/rtl/cart/cart.sv +@@ -690,13 +690,25 @@ ++ logic [8:0] prg_bank_mask; ++ logic [9:0] chr_bank_mask; ++ logic prg_is_pow2; ++ logic chr_is_pow2; ++ always_ff @(posedge clk) begin ++ prg_is_pow2 <= (prg_8k_count != 9'd0) && ((prg_8k_count & (prg_8k_count - 9'd1)) == 9'd0); ++ chr_is_pow2 <= (chr_1k_count != 10'd0) && ((chr_1k_count & (chr_1k_count - 10'd1)) == 10'd0); ++ prg_bank_mask <= (prg_8k_count == 9'd0) ? 9'd0 : 9'((1 << $clog2(prg_8k_count)) - 1); ++ chr_bank_mask <= (chr_1k_count == 10'd0) ? 10'd0 : 10'((1 << $clog2(chr_1k_count)) - 1); ++ end ++ + logic [8:0] prg_bank_sel; + logic [9:0] chr_bank_sel; + always_comb begin +- prg_bank_sel = (prg_8k_count == 9'd0) ? 9'd0 +- : 9'(prg_slot[cpu_addr[14:13]] % prg_8k_count); +- chr_bank_sel = (chr_1k_count == 10'd0) ? 10'd0 +- : 10'(chr_slot[chr_addr[12:10]] % chr_1k_count); ++ if (prg_is_pow2) ++ prg_bank_sel = prg_slot[cpu_addr[14:13]] & prg_bank_mask; ++ else ++ prg_bank_sel = (prg_8k_count == 9'd0) ? 9'd0 : 9'(prg_slot[cpu_addr[14:13]] % prg_8k_count); ++ if (chr_is_pow2) ++ chr_bank_sel = chr_slot[chr_addr[12:10]] & chr_bank_mask; ++ else ++ chr_bank_sel = (chr_1k_count == 10'd0) ? 10'd0 : 10'(chr_slot[chr_addr[12:10]] % chr_1k_count); + end +@@ -714,3 +726,4 @@ +- chr_wr_bank_sel = (chr_1k_count == 10'd0) ? 10'd0 +- : 10'(chr_slot[chr_wr_addr[12:10]] % chr_1k_count); ++ chr_wr_bank_sel = chr_is_pow2 ? (chr_slot[chr_wr_addr[12:10]] & chr_bank_mask) ++ : ((chr_1k_count == 10'd0) ? 10'd0 : 10'(chr_slot[chr_wr_addr[12:10]] % chr_1k_count)); +``` + +--- + +#### RTL-R2-04: Non-Power-of-2 Runtime Modulo/Division on Palette Download Path + +- **Severity / Category**: Medium / Arithmetic Logic Synthesis +- **Target File & Line Numbers**: `rtl/palette.sv:708-712` + +**Observation & Root Cause Analysis**: +In `palette.sv`: `pal_addr % 3` and `pal_addr / 3` run during custom palette file downloading. Non-power-of-2 integer division and modulo infer multi-stage combinational subtractor/comparator logic trees. Tracking color channels with a 2-bit counter (`0..2`) and the entry index with a 6-bit counter (`0..63`) eliminates all division logic. Because `palette.sv` does not declare an `rst_n` port, resetting and synchronizing `pal_ch_cnt` and `pal_entry_idx` whenever `pal_we && (pal_addr == 8'd0)` self-synchronizes each palette load without requiring an external reset net. + +**Concrete Drop-In Patch**: + +```diff +--- a/rtl/palette.sv ++++ b/rtl/palette.sv +@@ -707,7 +707,20 @@ ++ logic [1:0] pal_ch_cnt = 2'd0; ++ logic [5:0] pal_entry_idx = 6'd0; + always_ff @(posedge clk) begin +- if (pal_we && pal_addr < 8'd192) begin +- if (pal_addr % 3 == 0) cbase_r[pal_addr / 3] <= pal_data; +- else if (pal_addr % 3 == 1) cbase_g[pal_addr / 3] <= pal_data; +- else cbase_b[pal_addr / 3] <= pal_data; ++ if (pal_we && pal_addr < 8'd192) begin ++ if (pal_addr == 8'd0) begin ++ cbase_r[0] <= pal_data; ++ pal_ch_cnt <= 2'd1; ++ pal_entry_idx <= 6'd0; ++ end else begin ++ if (pal_ch_cnt == 2'd0) cbase_r[pal_entry_idx] <= pal_data; ++ else if (pal_ch_cnt == 2'd1) cbase_g[pal_entry_idx] <= pal_data; ++ else cbase_b[pal_entry_idx] <= pal_data; ++ if (pal_ch_cnt == 2'd2) begin ++ pal_ch_cnt <= 2'd0; ++ pal_entry_idx <= pal_entry_idx + 6'd1; ++ end else begin ++ pal_ch_cnt <= pal_ch_cnt + 2'd1; ++ end ++ end + end +``` + +--- + +#### RTL-R2-05: 4:1 Clock Domain Crossing Without Phase Enable Evaluating Multi-Bit Bus Skew + +- **Severity / Category**: High / Clock Domain Crossing & Routing Skew +- **Target File & Line Numbers**: `rtl/cart_sdram.sv:193-197, 337-351` + +**Observation & Root Cause Analysis**: +`cart_sdram` runs on `clk_sdram` (85.909 MHz). The input `chr_addr` (20 bits) originates on `clk_sys` (21.477 MHz). Line 195 evaluates `(chr_addr != chr_prev)` without a phase enable strobe. Because 20 address routing paths have varying physical propagation delays across the FPGA fabric, an intermediate `clk_sdram` edge occurring while `chr_addr` is transitioning can sample a partially toggled address, latching corrupted hybrid addresses into `a_chr_addr` and issuing spurious SDRAM reads. + +**Research Citations & Specifications**: +Cliff Cummings, *Clock Domain Crossing (CDC) Design & Verification Techniques*: Multi-bit bus signals crossing between synchronous frequency-multiple domains must be sampled only when qualified by a phase-synchronized clock enable strobe. + +**Concrete Drop-In Patch**: + +Declare `ce_sys_phase` in the module port list and qualify `chr_issue` (or generate the phase strobe internally by detecting the `clk_sys` edge within the `clk_sdram` domain): + +```diff +--- a/rtl/cart_sdram.sv ++++ b/rtl/cart_sdram.sv +@@ -25,6 +25,7 @@ module cart_sdram ( + input logic clk_sdram, + input logic reset, ++ input logic ce_sys_phase, // Phase strobe: clk_sys (21.477 MHz) valid edge in clk_sdram (85.909 MHz) +@@ -193,4 +194,4 @@ + logic chr_issue; + assign chr_issue = !chr_inflight +- && ((chr_addr != chr_prev) || !chr_held) ++ && ce_sys_phase && ((chr_addr != chr_prev) || !chr_held) +``` + +*(Alternatively, if `emu.sv` does not route an external phase enable, generate `ce_sys_phase` internally within `cart_sdram` via 2FF edge detection: `logic [1:0] clk_sys_sync; always_ff @(posedge clk_sdram) clk_sys_sync <= {clk_sys_sync[0], clk_sys}; wire ce_sys_phase = (clk_sys_sync == 2'b01);`)* + +--- + +#### RTL-R2-06: SDRAM Handshake Latency Stalls and Bus Latency Hazards + +- **Severity / Category**: Medium / SDRAM Controller Efficiency +- **Target File & Line Numbers**: `rtl/sdram.sv:728, 746` and `rtl/sdram_arbiter.sv:233-241` + +**Observation & Root Cause Analysis**: +`sdram.sv` inserts an unconditional `S_ACK` dead cycle for handshake clearance, and `sdram_arbiter.sv` refuses to grant new requests while `serving != SRC_NONE`. On back-to-back CHR-PRG accesses, the total read latency reaches 26 cycles of `clk_sdram` (302.6 ns), exceeding the 24-cycle CPU budget (279.3 ns) allowed before CPU clock phase 2 ends. + +**Concrete Drop-In Patch**: + +```diff +--- a/rtl/sdram.sv ++++ b/rtl/sdram.sv +@@ -728,3 +728,4 @@ module sdram #( + ack <= 1'b1; +- state <= S_ACK; ++ // Bypass S_ACK dead-cycle if requester already de-asserted req: ++ state <= (!req) ? S_IDLE : S_ACK; + end +--- a/rtl/sdram_arbiter.sv ++++ b/rtl/sdram_arbiter.sv +@@ -233,5 +233,7 @@ module sdram_arbiter #( ++ logic serving_finishing; ++ assign serving_finishing = (serving != SRC_NONE) && sdram_ack; + always_comb begin +- grant_chr = (serving == SRC_NONE) && ready && (chr_pend || chr_stb); +- grant_prg = (serving == SRC_NONE) && ready && !grant_chr ++ grant_chr = ((serving == SRC_NONE) || serving_finishing) && (ready || sdram_ack) && (chr_pend || chr_stb); ++ grant_prg = ((serving == SRC_NONE) || serving_finishing) && (ready || sdram_ack) && !grant_chr + && (prg_pend || prg_stb); +- grant_pd = (serving == SRC_NONE) && ready && !grant_chr && !grant_prg ++ grant_pd = ((serving == SRC_NONE) || serving_finishing) && (ready || sdram_ack) && !grant_chr && !grant_prg + && (pd_pend || pd_stb); +- grant_wr = (serving == SRC_NONE) && ready && !grant_chr && !grant_prg ++ grant_wr = ((serving == SRC_NONE) || serving_finishing) && (ready || sdram_ack) && !grant_chr && !grant_prg + && !grant_pd && wr_pend; +``` + +--- + +#### RTL-R2-07: Combinational OAM Read Bus Multiplexer Logic Depth Consuming 3,262 LEs + +- **Severity / Category**: Medium / FPGA Routing Depth +- **Target File & Line Numbers**: `rtl/ppu2c02.sv:502-503, 1045-1047, 2478` + +**Observation & Root Cause Analysis**: +In `RustyNES.map.rpt`, `oam_read` consumes 1,891 LEs for `oam_bus` multiplexing and 1,371 LEs for `cpu_dout` multiplexing (over 3,260 LEs combined). Synthesizing a 256:1 8-bit mux cascade with attribute bit masking in a single cycle creates excessive LUT levels. Partitioning OAM into four 64-byte banks reduces multiplexer depth from 8 levels of LUTs to 2 levels. + +**Concrete Drop-In Patch**: + +```diff +--- a/rtl/ppu2c02.sv ++++ b/rtl/ppu2c02.sv +@@ -1045,3 +1045,17 @@ module ppu2c02 #( + function automatic logic [7:0] oam_read(input logic [7:0] a); +- oam_read = (a[1:0] == 2'd2) ? (oam[a] & 8'hE3) : oam[a]; ++ logic [7:0] bank_data [0:3]; ++ bank_data[0] = oam[{2'b00, a[5:0]}]; ++ bank_data[1] = oam[{2'b01, a[5:0]}]; ++ bank_data[2] = oam[{2'b10, a[5:0]}]; ++ bank_data[3] = oam[{2'b11, a[5:0]}]; ++ logic [7:0] raw_val; ++ case (a[7:6]) ++ 2'b00: raw_val = bank_data[0]; ++ 2'b01: raw_val = bank_data[1]; ++ 2'b10: raw_val = bank_data[2]; ++ 2'b11: raw_val = bank_data[3]; ++ endcase ++ oam_read = (a[1:0] == 2'd2) ? (raw_val & 8'hE3) : raw_val; + endfunction +``` + +--- + +### Section 8: Security, FFI, Memory Safety & Cryptography + +#### SEC-R2-01: Uncontained Panics Across C-ABI in `rustynes-cosim` + +- **Severity / Category**: High / FFI Boundary Safety & Process Resilience +- **Target File & Line Numbers**: `crates/rustynes-cosim/src/lib.rs:637-975` (15 Exported Functions) + +**Observation & Root Cause Analysis**: +Fifteen exported C-ABI functions (`rn_open`, `rn_run_frame`, `rn_step_master_clock`, `rn_index_framebuffer`, etc.) interact with the emulation core without any panic boundary (`std::panic::catch_unwind`). Under Rust 2024 / RFC 2945, unwinding across an `extern "C"` boundary without an explicit `-unwind` ABI is undefined behavior or triggers an immediate `panic = abort`. Any internal assertion or parse error immediately terminates the host EDA simulator (Verilator/QuestaSim). + +**Research Citations & Specifications**: +Rust RFC 2945 ("c-unwind ABI"): Unwinding across `extern "C"` boundaries terminates the process unless guarded by `catch_unwind`. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-cosim/src/lib.rs: +macro_rules! catch_ffi { + ($default:expr, $body:expr) => { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)) { + Ok(res) => res, + Err(_) => $default, + } + }; +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rn_run_frame(handle: *mut c_void) -> c_ulonglong { + catch_ffi!(0, { + let o = oracle!(handle, 0); + o.nes.run_frame(); + o.nes.bus().cycle() + }) +} +``` + +--- + +#### SEC-R2-02: Pointer Alignment Violation in `core::ptr::copy_nonoverlapping::` + +- **Severity / Category**: Medium / Memory Safety & Undefined Behavior +- **Target File & Line Numbers**: `crates/rustynes-cosim/src/lib.rs:742-757` + +**Observation & Root Cause Analysis**: +In `rn_index_framebuffer`: + +```rust +unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; +``` + +`out` is typed as `*mut u16`. `core::ptr::copy_nonoverlapping::` strictly requires `out` to be aligned to `align_of::()` (2 bytes). External C callers may pass unaligned buffers. Passing an unaligned pointer constitutes immediate Undefined Behavior under the Rust abstract machine. + +**Research Citations & Specifications**: +The Rust Reference §Behavior considered undefined: Invoking typed pointer memory operations on improperly aligned pointers is undefined behavior. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-cosim/src/lib.rs:742-757: + if (out as usize) & (core::mem::align_of::() - 1) == 0 { + unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) }; + } else { + unsafe { + core::ptr::copy_nonoverlapping( + fb.as_ptr().cast::(), + out.cast::(), + fb.len() * core::mem::size_of::(), + ); + } + } +``` + +--- + +#### SEC-R2-03: Sandboxing & Denial of Service via Unbounded Heap Allocation in Piccolo WASM Lua + +- **Severity / Category**: High / Sandboxing & Memory Exhaustion DoS +- **Target File & Line Numbers**: `crates/rustynes-script/src/piccolo_backend.rs:700-723` + +**Observation & Root Cause Analysis**: +While the native `mlua` backend enforces `lua.set_memory_limit(SCRIPT_MEMORY_LIMIT)` (64 MiB), `PiccoloBackend` runs loop steps governed solely by instruction `Fuel`. A malicious script running in a browser tab can allocate massive tables or concatenated strings within a single frame's fuel budget, consuming all WebAssembly linear memory and crashing the browser tab. + +**Research Citations & Specifications**: +CWE-400 ("Uncontrolled Resource Consumption"): Untrusted scripting runtimes must enforce strict memory quotas alongside execution step budgets. + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-script/src/piccolo_backend.rs:700-723: + const MAX_HEAP_BYTES: usize = 64 * 1024 * 1024; // 64 MiB sandbox limit + let fuel_budget = fuel_for(self.budget.get()); + let res = self.lua.enter(|ctx| { + let ex = ctx.fetch(executor); + let mut fuel = Fuel::with(fuel_budget); + loop { + if ex.step(ctx, &mut fuel) { + break Ok(true); + } + if ctx.gc_metrics().total_allocation() > MAX_HEAP_BYTES { + break Err(ScriptError::Lua("memory limit exceeded".into())); + } + if !fuel.should_continue() { + break Ok(false); + } + } + }); +``` + +--- + +#### SEC-R2-04: Parser Robustness: Unchecked Addition Integer Overflow in ROM Slicing + +- **Severity / Category**: High / Parser Robustness & Panic Resiliency +- **Target File & Line Numbers**: `crates/rustynes-mappers/src/lib.rs:400-431` + +**Observation & Root Cause Analysis**: +In `rustynes_mappers::parse`, cursor additions (`cursor + h.prg_size` and `cursor + h.chr_size`) use bare `+` arithmetic. On 32-bit targets (`wasm32`, `thumbv7em`), if a corrupted NES 2.0 header declares `h.prg_size = 0xFFFFFFF0`, `cursor + h.prg_size` wraps around to `6`. The length check `bytes.len() < 6` evaluates to `false`, and the subsequent slice `bytes[16..6]` panics. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-mappers/src/lib.rs ++++ b/crates/rustynes-mappers/src/lib.rs +@@ -400,10 +400,14 @@ + let mut cursor = header::HEADER_LEN; + if h.has_trainer { +- if bytes.len() < cursor + header::TRAINER_LEN { ++ let next = cursor.checked_add(header::TRAINER_LEN).ok_or(RomError::Truncated { ++ needed: usize::MAX, ++ got: bytes.len(), ++ })?; ++ if bytes.len() < next { + return Err(RomError::Truncated { +- needed: cursor + header::TRAINER_LEN, ++ needed: next, + got: bytes.len(), + }); + } +- cursor += header::TRAINER_LEN; ++ cursor = next; + } +``` + +--- + +#### SEC-R2-05: Predictable Transaction IDs in STUN/TURN Netplay Traversal + +- **Severity / Category**: Medium / Cryptographic Randomness & Network Security +- **Target File & Line Numbers**: `crates/rustynes-netplay/src/relay.rs:342-350` and `crates/rustynes-mobile/src/lib.rs:3159-3169` + +**Observation & Root Cause Analysis**: +`TurnClient` generates 96-bit STUN/TURN transaction IDs using `SplitMix64` seeded with `nanos ^ stack.rotate_left(17)`. A 64-bit non-cryptographic PRNG cannot provide 96 bits of entropy. An off-path network attacker can predict transaction IDs, forge allocation responses, and hijack relayed netplay sessions. + +**Research Citations & Specifications**: +RFC 5389 §6 / RFC 8489 §5 ("STUN Transaction IDs"): "Transaction IDs MUST be chosen from a cryptographically random source with uniform distribution." + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-netplay/src/relay.rs ++++ b/crates/rustynes-netplay/src/relay.rs +@@ -342,7 +342,12 @@ impl TurnClient { + fn next_tx(&mut self) -> TransactionId { + let mut tx = [0u8; 12]; +- for chunk in tx.chunks_mut(4) { +- let r = self.rng.next_u64().to_le_bytes(); +- let n = chunk.len(); +- chunk.copy_from_slice(&r[..n]); ++ if getrandom::getrandom(&mut tx).is_err() { ++ for chunk in tx.chunks_mut(4) { ++ let r = self.rng.next_u64().to_le_bytes(); ++ let n = chunk.len(); ++ chunk.copy_from_slice(&r[..n]); ++ } + } + tx + } +--- a/crates/rustynes-mobile/src/lib.rs ++++ b/crates/rustynes-mobile/src/lib.rs +@@ -3159,3 +3159,7 @@ fn nondeterministic_seed() -> u64 { ++ let mut buf = [0u8; 8]; ++ if getrandom::getrandom(&mut buf).is_ok() { ++ return u64::from_le_bytes(buf); ++ } + use std::time::{SystemTime, UNIX_EPOCH}; +``` + +--- + +#### SEC-R2-06: Transmutation of Raw Integer to Function Pointer Undefined Behavior + +- **Severity / Category**: High / Memory Safety & Undefined Behavior +- **Target File & Line Numbers**: `crates/rustynes-cheevos/src/http.rs:98-105, 260` and `crates/rustynes-cheevos/src/ffi.rs:53-54` + +**Observation & Root Cause Analysis**: +`http.rs:103` executes `std::mem::transmute::(done.callback)`. In Rust, bare function pointers (`fn(args)`) have a non-null niche. If `done.callback` is 0 (null), transmuting `0usize` to a bare `fn` pointer produces immediate Undefined Behavior. Calling it dereferences a null pointer, causing a segmentation fault. +Furthermore, typed as an `Option`, `callback` cannot be cast via bare `callback as usize` at line 260 (`error[E0605]: non-primitive cast`), and must be extracted using `callback.map_or(0, |f| f as usize)`. + +**Research Citations & Specifications**: +Rustonomicon: Nullable C function pointers must be typed as `Option` to preserve the non-null niche invariant. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-cheevos/src/ffi.rs ++++ b/crates/rustynes-cheevos/src/ffi.rs +@@ -53,2 +53,2 @@ +-pub type rc_client_server_callback_t = unsafe extern "C" fn( ++pub type rc_client_server_callback_t = Option; + +--- a/crates/rustynes-cheevos/src/http.rs ++++ b/crates/rustynes-cheevos/src/http.rs +@@ -98,7 +98,7 @@ + let cb: ffi::rc_client_server_callback_t = { +- unsafe { +- std::mem::transmute::(done.callback) +- } ++ if done.callback == 0 { ++ None ++ } else { ++ unsafe { ++ std::mem::transmute::(done.callback) ++ } ++ } + }; +@@ -113,1 +113,3 @@ +- cb(&raw const response, callback_data); ++ if let Some(cb_fn) = cb { ++ cb_fn(&raw const response, callback_data); ++ } +@@ -259,3 +261,3 @@ +- let cb_bits = callback as usize; ++ let cb_bits = callback.map_or(0, |f| f as usize); +``` + +--- + +#### SEC-R2-07: Plaintext Token Exposure in Debug Formatters & World-Readable Config Permissions + +- **Severity / Category**: Low / Information Disclosure & Credential Hygiene +- **Target File & Line Numbers**: `crates/rustynes-cheevos/src/client.rs:278-295` and `crates/rustynes-frontend/src/atomic_write.rs:590-595` + +**Observation & Root Cause Analysis**: +`RaUser` derives `Debug` with field `pub token: String`, leaking session tokens into terminal logs. In `atomic_write.rs`, newly created configuration files default to the process umask (`0644`/`0664`, world-readable on Unix). Other users on a multi-user machine can read `config.toml` and exfiltrate the RetroAchievements session token. + +**Research Citations & Specifications**: +CWE-732 ("Incorrect Permission Assignment for Critical Resource") & CWE-532 ("Insertion of Sensitive Information into Log File"). + +**Concrete Drop-In Patch**: + +```rust +// In crates/rustynes-cheevos/src/client.rs: +impl std::fmt::Debug for RaUser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RaUser") + .field("display_name", &self.display_name) + .field("username", &self.username) + .field("token", &"") + .finish() + } +} + +// In crates/rustynes-frontend/src/atomic_write.rs: + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + opts.mode(existing_mode.unwrap_or(0o600)); + } +``` + +--- + +#### SEC-R2-08: Static Lifetime Erasure Transmutation in `wasm_cheevos` Closure Bridge + +- **Severity / Category**: Medium / Memory Safety & Lifetime Invariants +- **Target File & Line Numbers**: `crates/rustynes-frontend/src/wasm_cheevos.rs:205-217` + +**Observation & Root Cause Analysis**: +`wasm_cheevos.rs:209` erases the lifetime of a local stack closure `read: &mut dyn FnMut(u16) -> u8` by transmuting it to `'static` before passing it to JavaScript. If the JavaScript host retains a reference post-return, subsequent calls access a deallocated stack frame, causing Use-After-Free in WebAssembly memory. + +**Concrete Drop-In Patch**: + +```diff +--- a/crates/rustynes-frontend/src/wasm_cheevos.rs ++++ b/crates/rustynes-frontend/src/wasm_cheevos.rs +@@ -205,10 +205,21 @@ impl BrowserRaSession { +- #[allow(unsafe_code)] // localized lifetime erasure; see SAFETY below +- // SAFETY: the erased pointer is only dereferenced by `ra_do_frame` +- // synchronously below, and `closure` is dropped at the end of this +- // function, so the call can never outlive the real borrow. +- let read_static: &mut (dyn FnMut(u16) -> u8 + 'static) = +- unsafe { core::mem::transmute(read) }; ++ use std::sync::atomic::{AtomicBool, Ordering}; ++ use std::sync::Arc; ++ let alive = Arc::new(AtomicBool::new(true)); ++ let alive_flag = Arc::clone(&alive); ++ let read_ptr: *mut (dyn FnMut(u16) -> u8) = read; + let closure = Closure:: u32>::new(move |addr: u32| { +- u32::from(read_static((addr & 0xffff) as u16)) ++ if !alive_flag.load(Ordering::Acquire) { ++ return 0; ++ } ++ // SAFETY: `alive` is set false before `read` goes out of scope, ++ // preventing post-return invocation from dereferencing the stack pointer. ++ unsafe { ++ let reader = &mut *read_ptr; ++ u32::from(reader((addr & 0xffff) as u16)) ++ } + }); + let json = ra_do_frame(closure.as_ref().unchecked_ref()); ++ alive.store(false, Ordering::Release); + drop(closure); + parse_unlock_titles(&json) +``` + +--- + +## 4. Remediation Priority Matrix + +The 61 findings are organized into four prioritized implementation phases based on severity, runtime impact, and implementation dependencies: + +| Phase / Order | Finding ID | Severity | Category | Target Subsystem | Estimated Effort | Dependencies | +|:---:|---|:---:|---|---|:---:|---| +| **Phase 1** | **CORE-R2-01** | **Critical** | Hardware Parity | `rustynes-cpu` / `rustynes-core` | Low (10 LOC) | None | +| **Phase 1** | **NET-R2-01** | **Critical** | Performance | `rustynes-netplay` | Medium (30 LOC) | None | +| **Phase 1** | **RTL-R2-01** | **Critical** | Audio DSP Math | `RustyNES_MiSTer/rtl` | Low (15 LOC) | None | +| **Phase 1** | **NET-R2-07** | **High** | Security / DoS | `rustynes-netplay` | Low (15 LOC) | None | +| **Phase 1** | **SEC-R2-01** | **High** | FFI / Panic Safety | `rustynes-cosim` | Medium (40 LOC) | None | +| **Phase 1** | **SEC-R2-04** | **High** | Parser Robustness | `rustynes-mappers` | Low (20 LOC) | None | +| **Phase 1** | **SEC-R2-06** | **High** | Memory Safety | `rustynes-cheevos` | Low (15 LOC) | None | +| **Phase 2** | **APU-R2-01** | **High** | Concurrency | `rustynes-frontend` | Low (25 LOC) | None | +| **Phase 2** | **APU-R2-02** | **High** | Performance | `rustynes-apu` | Low (10 LOC) | None | +| **Phase 2** | **PPU-R2-01** | **High** | Savestate Parity | `rustynes-ppu` | Low (5 LOC) | None | +| **Phase 2** | **PPU-R2-02** | **High** | WGSL Compliance | `rustynes-gfx-shaders` | Low (20 LOC) | None | +| **Phase 2** | **PPU-R2-04** | **High** | Thread Contention | `rustynes-frontend` | Low (10 LOC) | None | +| **Phase 2** | **CORE-R2-02** | **High** | Bus Dispatch | `rustynes-core` | Low (15 LOC) | None | +| **Phase 2** | **MAP-R2-01** | **High** | Startup Latency | `rustynes-gamedb` | Medium (45 LOC) | None | +| **Phase 2** | **MAP-R2-02** | **High** | Bus Performance | `rustynes-mappers` | Medium (60 LOC) | None | +| **Phase 2** | **MAP-R2-04** | **High** | Submapper Parity | `rustynes-mappers` | Low (25 LOC) | None | +| **Phase 2** | **NET-R2-02** | **High** | Serialization | `rustynes-netplay` | Low (10 LOC) | None | +| **Phase 2** | **NET-R2-03** | **High** | Network Bandwidth | `rustynes-netplay` | Medium (50 LOC) | None | +| **Phase 2** | **NET-R2-04** | **High** | Reliability | `rustynes-netplay` | Low (10 LOC) | None | +| **Phase 2** | **NET-R2-06** | **High** | Threading | `rustynes-frontend` | High (80 LOC) | None | +| **Phase 2** | **NET-R2-08** | **High** | Liveness | `rustynes-frontend` | Medium (40 LOC) | None | +| **Phase 2** | **MOB-R2-01** | **High** | FFI Heap Churn | `rustynes-mobile` | Medium (40 LOC) | None | +| **Phase 2** | **AND-R2-01** | **High** | Audio Pacing | `android/` | Medium (35 LOC) | None | +| **Phase 2** | **IOS-R2-01** | **High** | GPU Bandwidth | `rustynes-ios` | Low (25 LOC) | None | +| **Phase 2** | **RTL-R2-02** | **High** | BRAM Area | `RustyNES_MiSTer/rtl` | Medium (50 LOC) | None | +| **Phase 2** | **RTL-R2-03** | **High** | Timing Closure | `RustyNES_MiSTer/rtl` | Low (25 LOC) | None | +| **Phase 2** | **RTL-R2-05** | **High** | CDC Skew | `RustyNES_MiSTer/rtl` | Low (15 LOC) | None | +| **Phase 2** | **SEC-R2-03** | **High** | Sandboxing / DoS | `rustynes-script` | Low (20 LOC) | None | +| **Phase 3** | **APU-R2-03** | **Medium** | SIMD Decimation | `rustynes-apu` | Low (25 LOC) | None | +| **Phase 3** | **APU-R2-04** | **Medium** | Filter Branches | `rustynes-apu` | Low (25 LOC) | None | +| **Phase 3** | **APU-R2-06** | **Medium** | Cache Footprint | `rustynes-frontend` | Low (30 LOC) | None | +| **Phase 3** | **PPU-R2-03** | **Medium** | Shader Bandwidth | `rustynes-gfx-shaders` | Low (20 LOC) | None | +| **Phase 3** | **PPU-R2-05** | **Medium** | Arithmetic Hoisting | `rustynes-ppu` | Low (15 LOC) | None | +| **Phase 3** | **CORE-R2-03** | **Medium** | Dead Code | `rustynes-cpu` | Low (10 LOC) | None | +| **Phase 3** | **CORE-R2-04** | **Medium** | Register ABI | `rustynes-cpu` | Medium (50 LOC) | None | +| **Phase 3** | **CORE-R2-05** | **Medium** | Allocation Hoisting | `rustynes-core` | Low (15 LOC) | None | +| **Phase 3** | **CORE-R2-06** | **Medium** | Cache Layout | `rustynes-core` | Low (20 LOC) | None | +| **Phase 3** | **MAP-R2-03** | **Medium** | Expansion Audio | `rustynes-mappers` | Medium (40 LOC) | None | +| **Phase 3** | **MAP-R2-05** | **Medium** | Dirty Tracking | `rustynes-mappers` | Low (20 LOC) | None | +| **Phase 3** | **MAP-R2-06** | **Medium** | Compatibility | `rustynes-mappers` | Low (15 LOC) | None | +| **Phase 3** | **NET-R2-05** | **Medium** | Fast Hashing | `rustynes-netplay` | Low (20 LOC) | None | +| **Phase 3** | **NET-R2-09** | **Medium** | Vector Pruning | `rustynes-netplay` | Low (25 LOC) | None | +| **Phase 3** | **LIB-R2-01** | **Medium** | Video Transcoding | `rustynes-libretro` | Low (20 LOC) | None | +| **Phase 3** | **MOB-R2-02** | **Medium** | Wake Lock Lifecycle | `android/` & `ios/` | Low (25 LOC) | None | +| **Phase 3** | **AND-R2-02** | **Medium** | Memory Leak | `android/` | Low (10 LOC) | None | +| **Phase 3** | **RTL-R2-04** | **Medium** | Palette Arithmetic | `RustyNES_MiSTer/rtl` | Low (20 LOC) | None | +| **Phase 3** | **RTL-R2-06** | **Medium** | SDRAM Pipelining | `RustyNES_MiSTer/rtl` | Medium (45 LOC) | None | +| **Phase 3** | **RTL-R2-07** | **Medium** | OAM Mux Depth | `RustyNES_MiSTer/rtl` | Medium (50 LOC) | None | +| **Phase 3** | **SEC-R2-02** | **Medium** | Pointer Alignment | `rustynes-cosim` | Low (15 LOC) | None | +| **Phase 3** | **SEC-R2-05** | **Medium** | CSPRNG Traversal | `rustynes-netplay` | Low (20 LOC) | None | +| **Phase 3** | **SEC-R2-08** | **Medium** | Closure Safety | `rustynes-frontend` | Low (15 LOC) | None | +| **Phase 4** | **APU-R2-05** | **Low** | Float Division | `rustynes-frontend` | Low (5 LOC) | None | +| **Phase 4** | **APU-R2-07** | **Low** | Pulse Comment | `rustynes-apu` | Low (5 LOC) | None | +| **Phase 4** | **APU-R2-08** | **Low** | Noise Comment | `rustynes-apu` | Low (5 LOC) | None | +| **Phase 4** | **PPU-R2-06** | **Low** | OAM Clear Writes | `rustynes-ppu` | Low (5 LOC) | None | +| **Phase 4** | **PPU-R2-07** | **Low** | Documentation | `rustynes-ppu` | Low (5 LOC) | None | +| **Phase 4** | **CORE-R2-07** | **Low** | Documentation | `rustynes-core` | Low (5 LOC) | None | +| **Phase 4** | **MAP-R2-07** | **Low** | Hardware Spec | `rustynes-mappers` | Low (10 LOC) | None | +| **Phase 4** | **MOB-R2-03** | **Low** | HD Allocations | `rustynes-mobile` | Low (15 LOC) | None | +| **Phase 4** | **LIB-R2-02** | **Low** | Core Options / Audio | `rustynes-libretro` | Low (20 LOC) | None | +| **Phase 4** | **SEC-R2-07** | **Low** | Token Redaction | `rustynes-cheevos` | Low (15 LOC) | None | + +--- + +*Report synthesized and certified by Audit Report Synthesis Worker (`worker_audit_writer_r2`).* From 3d13e18a55f6b5476d8e2939d078330fc9f90c2c Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 01:57:16 -0400 Subject: [PATCH 056/104] test(coverage): baseline 25 newly staged dumps, with title-screen captures Twenty-five commercial and unlicensed dumps were staged for v3.2.0's mapper families (1, 11, 13, 34, 66, 79, 118, 140, 206, 232). Each now has an external_coverage snapshot and a screenshot under screenshots/external//: 19 new, 6 replaced. The default capture script presses START five times, which for 11 of these lands on a save menu, a select screen or, for Karnov, the PAUSE banner. capture_override() now gives them a script chosen by looking at the frames around it (a scratch probe dumped every 100 frames): - IDLE (700): Castle of Deceit, Goal! Two, NES Play Action Football (an intro cutscene), Pro Sport Hockey, Mississippi Satsujin Jiken, Family Circuit. - IDLE_LONG (1200): Dragon Warrior III's stone title, Babel no Tou's attract-mode play. - New IDLE_1800: Dragon Warrior IV, whose title logo finishes drawing at about f1700. - New IDLE_1500: Armadillo (Japan), whose title follows a story crawl from about f1400. The START taps reach a "Battle Box not connected" notice. - New ONE_START: Karnov. A single START tap at f300 starts the game, which is in play by f700. The default's later taps pause it. An override changes the captured frame, so those 11 snapshots were blessed after the override and their final frames were viewed before copying. The other 14 keep the default script; their frames were viewed and show a title or gameplay. Verified: - The filtered bless run over the 11 (plus their already-tracked .zip variants, whose snapshots did not move) passes. - The earlier 52-ROM filtered run was green. - `coverage.py categorize --dry-run` reports no relocations. No commercial ROM is committed; the dumps stay in the gitignored tests/roms/external/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../tests/external_coverage.rs | 37 +++++++++++++++++- ...e__mapper_001_MMC1_Dragon_Warrior_III.snap | 12 ++++++ ...ge__mapper_001_MMC1_Dragon_Warrior_IV.snap | 12 ++++++ ...rDreams_Bible_Adventures_USA_v1_5_Unl.snap | 13 ++++++ ..._ColorDreams_Castle_of_Deceit_USA_Unl.snap | 12 ++++++ ...apper_011_ColorDreams_Chiller_USA_Unl.snap | 13 ++++++ ...age__mapper_013_CPROM_Videomation_USA.snap | 13 ++++++ ...apper_034_BNROM_NINA001_Deadly_Towers.snap | 13 ++++++ ...NINA001_Impossible_Mission_II_USA_Unl.snap | 13 ++++++ ...ge__mapper_066_GxROM_Dragon_Power_USA.snap | 13 ++++++ ...per_079_NINA003_006_Blackjack_USA_Unl.snap | 13 ++++++ ...9_NINA003_006_Deathbots_USA_Rev_1_Unl.snap | 13 ++++++ ...rike_Aerial_Attack_Force_USA_v1_1_Unl.snap | 13 ++++++ ...ge__mapper_118_TxSROM_Armadillo_Japan.snap | 12 ++++++ ..._coverage__mapper_118_TxSROM_Goal_Two.snap | 12 ++++++ ...r_118_TxSROM_NES_Play_Action_Football.snap | 12 ++++++ ...e__mapper_118_TxSROM_Pro_Sport_Hockey.snap | 12 ++++++ ...nshi_Dan_Increaser_Tono_Tatakai_Japan.snap | 13 ++++++ ...ississippi_Satsujin_Jiken_Japan_Rev_A.snap | 12 ++++++ ...apper_140_Jaleco140_Youkai_Club_Japan.snap | 13 ++++++ ..._Babel_no_Tou_Japan_Namcot_Collection.snap | 12 ++++++ ...SA_Europe_Namco_Museum_Archives_Vol_2.snap | 13 ++++++ ...amily_Circuit_Japan_Namcot_Collection.snap | 12 ++++++ ...coverage__mapper_206_Namcot118_Karnov.snap | 13 ++++++ ...ica_Quattro_Quattro_Adventure_USA_Unl.snap | 13 ++++++ ...merica_Quattro_Quattro_Sports_USA_Unl.snap | 13 ++++++ .../mapper-001-MMC1/Dragon Warrior III.png | Bin 0 -> 11175 bytes .../mapper-001-MMC1/Dragon Warrior IV.png | Bin 0 -> 5343 bytes .../Bible Adventures (USA) (v1.5) (Unl).png | Bin 6031 -> 5910 bytes .../Castle of Deceit (USA) (Unl).png | Bin 0 -> 5503 bytes .../Chiller (USA) (Unl).png | Bin 0 -> 4822 bytes .../mapper-013-CPROM/Videomation (USA).png | Bin 1943 -> 1420 bytes .../Deadly Towers.png | Bin 0 -> 7086 bytes .../Impossible Mission II (USA) (Unl).png | Bin 6818 -> 5984 bytes .../mapper-066-GxROM/Dragon Power (USA).png | Bin 0 -> 2703 bytes .../Blackjack (USA) (Unl).png | Bin 0 -> 3589 bytes .../Deathbots (USA) (Rev 1) (Unl).png | Bin 0 -> 6238 bytes ...Aerial Attack Force (USA) (v1.1) (Unl).png | Bin 0 -> 3822 bytes .../mapper-118-TxSROM/Armadillo (Japan).png | Bin 0 -> 6789 bytes .../external/mapper-118-TxSROM/Goal! Two.png | Bin 3502 -> 3654 bytes .../NES Play Action Football.png | Bin 0 -> 4331 bytes .../mapper-118-TxSROM/Pro Sport Hockey.png | Bin 0 -> 5186 bytes ...i Dan - Increaser Tono Tatakai (Japan).png | Bin 0 -> 2730 bytes ...issippi Satsujin Jiken (Japan) (Rev A).png | Bin 4383 -> 4725 bytes ...bel no Tou (Japan) (Namcot Collection).png | Bin 0 -> 6821 bytes ... Europe) (Namco Museum Archives Vol 2).png | Bin 0 -> 15831 bytes ...ly Circuit (Japan) (Namcot Collection).png | Bin 0 -> 3201 bytes .../external/mapper-206-Namcot118/Karnov.png | Bin 0 -> 5556 bytes .../Quattro Adventure (USA) (Unl).png | Bin 0 -> 8154 bytes .../Quattro Sports (USA) (Unl).png | Bin 0 -> 4604 bytes 50 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_III.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_IV.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Bible_Adventures_USA_v1_5_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Castle_of_Deceit_USA_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Chiller_USA_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_013_CPROM_Videomation_USA.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Deadly_Towers.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Impossible_Mission_II_USA_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_066_GxROM_Dragon_Power_USA.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Blackjack_USA_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Deathbots_USA_Rev_1_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Double_Strike_Aerial_Attack_Force_USA_v1_1_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Armadillo_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Goal_Two.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_NES_Play_Action_Football.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Pro_Sport_Hockey.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Bio_Senshi_Dan_Increaser_Tono_Tatakai_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Mississippi_Satsujin_Jiken_Japan_Rev_A.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Youkai_Club_Japan.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Babel_no_Tou_Japan_Namcot_Collection.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Dragon_Buster_II_Yami_no_Fuuin_USA_Europe_Namco_Museum_Archives_Vol_2.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Family_Circuit_Japan_Namcot_Collection.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Karnov.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Adventure_USA_Unl.snap create mode 100644 crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Sports_USA_Unl.snap create mode 100644 screenshots/external/mapper-001-MMC1/Dragon Warrior III.png create mode 100644 screenshots/external/mapper-001-MMC1/Dragon Warrior IV.png create mode 100644 screenshots/external/mapper-011-ColorDreams/Castle of Deceit (USA) (Unl).png create mode 100644 screenshots/external/mapper-011-ColorDreams/Chiller (USA) (Unl).png create mode 100644 screenshots/external/mapper-034-BNROM-NINA001/Deadly Towers.png create mode 100644 screenshots/external/mapper-066-GxROM/Dragon Power (USA).png create mode 100644 screenshots/external/mapper-079-NINA003-006/Blackjack (USA) (Unl).png create mode 100644 screenshots/external/mapper-079-NINA003-006/Deathbots (USA) (Rev 1) (Unl).png create mode 100644 screenshots/external/mapper-079-NINA003-006/Double Strike - Aerial Attack Force (USA) (v1.1) (Unl).png create mode 100644 screenshots/external/mapper-118-TxSROM/Armadillo (Japan).png create mode 100644 screenshots/external/mapper-118-TxSROM/NES Play Action Football.png create mode 100644 screenshots/external/mapper-118-TxSROM/Pro Sport Hockey.png create mode 100644 screenshots/external/mapper-140-Jaleco140/Bio Senshi Dan - Increaser Tono Tatakai (Japan).png create mode 100644 screenshots/external/mapper-206-Namcot118/Babel no Tou (Japan) (Namcot Collection).png create mode 100644 screenshots/external/mapper-206-Namcot118/Dragon Buster II - Yami no Fuuin (USA, Europe) (Namco Museum Archives Vol 2).png create mode 100644 screenshots/external/mapper-206-Namcot118/Family Circuit (Japan) (Namcot Collection).png create mode 100644 screenshots/external/mapper-206-Namcot118/Karnov.png create mode 100644 screenshots/external/mapper-232-Camerica-Quattro/Quattro Adventure (USA) (Unl).png create mode 100644 screenshots/external/mapper-232-Camerica-Quattro/Quattro Sports (USA) (Unl).png diff --git a/crates/rustynes-test-harness/tests/external_coverage.rs b/crates/rustynes-test-harness/tests/external_coverage.rs index 9245a927..f0a1110b 100644 --- a/crates/rustynes-test-harness/tests/external_coverage.rs +++ b/crates/rustynes-test-harness/tests/external_coverage.rs @@ -336,6 +336,18 @@ fn capture_override(rom_rel: &str) -> Option { resets: &[700], frames: 3500, }; + // v3.2.0: titles whose own title screen or attract mode draws only after a + // long intro (a story crawl, a slow logo), past both IDLE and IDLE_LONG. + // Each frame count was chosen by looking at the frames around it. + const IDLE_1500: InputScript = InputScript::IdleOnly { frames: 1500 }; + const IDLE_1800: InputScript = InputScript::IdleOnly { frames: 1800 }; + // v3.2.0: Karnov. The default's five START taps PAUSE it in play (the + // capture showed "PAUSE"); one tap starts the game, in play by f700. + const ONE_START: InputScript = InputScript::Presses { + presses: &[(300, Buttons::START)], + frames: 1100, + checkpoints: &[700, 1100], + }; let idle = [ "mapper-000-NROM/Gyromite.nes", "mapper-001-MMC1/Dr. Mario.nes", @@ -350,8 +362,31 @@ fn capture_override(rom_rel: &str) -> Option { "mapper-085-VRC7/Lagrange Point (Japan) (En) (1.01).nes", "mapper-119-TQROM/High Speed (E) [!].nes", "mapper-119-TQROM/Pin Bot (E) [!].nes", + // v3.2.0: the title (or, for Play Action Football, an intro cutscene) + // instead of the menu or select screen the START taps reach. + "mapper-011-ColorDreams/Castle of Deceit (USA) (Unl).nes", + "mapper-118-TxSROM/Goal! Two.nes", + "mapper-118-TxSROM/NES Play Action Football.nes", + "mapper-118-TxSROM/Pro Sport Hockey.nes", + "mapper-140-Jaleco140/Mississippi Satsujin Jiken (Japan) (Rev A).nes", + "mapper-206-Namcot118/Family Circuit (Japan) (Namcot Collection).nes", + ]; + let idle_long = [ + "mapper-001-MMC1/Tecmo Bowl.nes", + // v3.2.0: DW3's stone title (the START taps reach the save menu) and + // Babel no Tou's attract-mode play (they reach the floor select). + "mapper-001-MMC1/Dragon Warrior III.nes", + "mapper-206-Namcot118/Babel no Tou (Japan) (Namcot Collection).nes", ]; - let idle_long = ["mapper-001-MMC1/Tecmo Bowl.nes"]; + match rom_rel { + // DW4's title logo finishes drawing at about f1700. + "mapper-001-MMC1/Dragon Warrior IV.nes" => return Some(IDLE_1800), + // Armadillo's title follows a story crawl, from about f1400 (the START + // taps reach a "Battle Box not connected" notice). + "mapper-118-TxSROM/Armadillo (Japan).nes" => return Some(IDLE_1500), + "mapper-206-Namcot118/Karnov.nes" => return Some(ONE_START), + _ => {} + } if rom_rel == "mapper-230-22in1-Reset/22-in-1 (Reset Based) [p1].zip" { return Some(RESET_22_IN_1); } diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_III.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_III.snap new file mode 100644 index 00000000..3920f870 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_III.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-001-MMC1/Dragon Warrior III.nes +rom_sha256=162d0eb965141d489864d04597ec7d04b9e73ed1890fda7f377e9ef781c9006c +frames=1200 +fb_bytes=245760 +cycles=35706821 +audio_samples=879798 +audio_fnv1a64=c3a1fc6163b22710 +checkpoint f1200 fb_fnv1a64=003f0825d3994ef5 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_IV.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_IV.snap new file mode 100644 index 00000000..481e82d0 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_001_MMC1_Dragon_Warrior_IV.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-001-MMC1/Dragon Warrior IV.nes +rom_sha256=4c703cc794d6935db939bf590ccaf8183ed03e1b150252782c073a2d28629f50 +frames=1800 +fb_bytes=245760 +cycles=53575126 +audio_samples=1320073 +audio_fnv1a64=6a872ee8880b6436 +checkpoint f1800 fb_fnv1a64=f12c71052adfc3b1 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Bible_Adventures_USA_v1_5_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Bible_Adventures_USA_v1_5_Unl.snap new file mode 100644 index 00000000..f49d9432 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Bible_Adventures_USA_v1_5_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-011-ColorDreams/Bible Adventures (USA) (v1.5) (Unl).nes +rom_sha256=4f39cb6e6d42582b967e72a2dbfe4e02382cf53de721d16f7f6950e6ae6b17ab +frames=1141 +fb_bytes=245760 +cycles=33949773 +audio_samples=836505 +audio_fnv1a64=eabc9611dbb08e58 +checkpoint f900 fb_fnv1a64=f45f10934f825247 +checkpoint f1100 fb_fnv1a64=a47b11930b935d47 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Castle_of_Deceit_USA_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Castle_of_Deceit_USA_Unl.snap new file mode 100644 index 00000000..65db08bc --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Castle_of_Deceit_USA_Unl.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-011-ColorDreams/Castle of Deceit (USA) (Unl).nes +rom_sha256=2d86b7059778bde8b660e96d2c94e185348e08678e9985c4c42160ef00f1bd9d +frames=700 +fb_bytes=245760 +cycles=20816572 +audio_samples=512903 +audio_fnv1a64=ed0f2dccea495a57 +checkpoint f700 fb_fnv1a64=1191b7ec4f79d29b diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Chiller_USA_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Chiller_USA_Unl.snap new file mode 100644 index 00000000..5a4714af --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_011_ColorDreams_Chiller_USA_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-011-ColorDreams/Chiller (USA) (Unl).nes +rom_sha256=8961cd11e16b34413263a3209605c92e6156dd6c2313c029fab9d6df998f3a12 +frames=1141 +fb_bytes=245760 +cycles=33949774 +audio_samples=836505 +audio_fnv1a64=1a6558afc381d8c0 +checkpoint f900 fb_fnv1a64=8290fed06a8d80a5 +checkpoint f1100 fb_fnv1a64=8290fed06a8d80a5 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_013_CPROM_Videomation_USA.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_013_CPROM_Videomation_USA.snap new file mode 100644 index 00000000..7a3cd9ba --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_013_CPROM_Videomation_USA.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-013-CPROM/Videomation (USA).nes +rom_sha256=3b33fe1b73d99b1231b1b384ded66504905e3169876648e06bf557edf3ebe7bd +frames=1141 +fb_bytes=245760 +cycles=33949775 +audio_samples=836505 +audio_fnv1a64=fa7b6130a1ff832d +checkpoint f900 fb_fnv1a64=0ef97b8869b4f2a2 +checkpoint f1100 fb_fnv1a64=0ef97b8869b4f2a2 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Deadly_Towers.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Deadly_Towers.snap new file mode 100644 index 00000000..b7885f7f --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Deadly_Towers.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-034-BNROM-NINA001/Deadly Towers.nes +rom_sha256=c3f4257a4909b3db8624eb6ce39050d006150df66d795ce35599f48cafed7692 +frames=1141 +fb_bytes=245760 +cycles=33949777 +audio_samples=836505 +audio_fnv1a64=783d99cc51a87bcf +checkpoint f900 fb_fnv1a64=b28e41cba43df5c5 +checkpoint f1100 fb_fnv1a64=b28e41cba43df5c5 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Impossible_Mission_II_USA_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Impossible_Mission_II_USA_Unl.snap new file mode 100644 index 00000000..98b574e4 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_034_BNROM_NINA001_Impossible_Mission_II_USA_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-034-BNROM-NINA001/Impossible Mission II (USA) (Unl).nes +rom_sha256=8f7c90ec942e3afd6ed00decc170005b0b5d7ad2d27b92c060e7d0e0c00a7487 +frames=1141 +fb_bytes=245760 +cycles=33949775 +audio_samples=836505 +audio_fnv1a64=177ea1f11dc95c50 +checkpoint f900 fb_fnv1a64=85830cc390da1a0d +checkpoint f1100 fb_fnv1a64=85830cc390da1a0d diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_066_GxROM_Dragon_Power_USA.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_066_GxROM_Dragon_Power_USA.snap new file mode 100644 index 00000000..e6fc9259 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_066_GxROM_Dragon_Power_USA.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-066-GxROM/Dragon Power (USA).nes +rom_sha256=e52cc421c726181a35f0cc9e8969a8aad50abd17bdc7eea59dc065990a576147 +frames=1141 +fb_bytes=245760 +cycles=33949782 +audio_samples=836505 +audio_fnv1a64=f23d1d66017632dc +checkpoint f900 fb_fnv1a64=ec0e9a58e764f3e0 +checkpoint f1100 fb_fnv1a64=6a91ff4f62a9a940 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Blackjack_USA_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Blackjack_USA_Unl.snap new file mode 100644 index 00000000..3717d3d8 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Blackjack_USA_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-079-NINA003-006/Blackjack (USA) (Unl).nes +rom_sha256=de0d08a054f06fc0a43709ee0b652aa99af06b566b53ff14ba897839dc33f65b +frames=1141 +fb_bytes=245760 +cycles=33949787 +audio_samples=836505 +audio_fnv1a64=8f68180ef048ec8a +checkpoint f900 fb_fnv1a64=98818a08a5d0ad62 +checkpoint f1100 fb_fnv1a64=98818a08a5d0ad62 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Deathbots_USA_Rev_1_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Deathbots_USA_Rev_1_Unl.snap new file mode 100644 index 00000000..23184757 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Deathbots_USA_Rev_1_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-079-NINA003-006/Deathbots (USA) (Rev 1) (Unl).nes +rom_sha256=a17839ccdd372db8970c57a5c4ce0cd9f38b14515a44f018d2d4a74dde53e84b +frames=1141 +fb_bytes=245760 +cycles=33949788 +audio_samples=836505 +audio_fnv1a64=31724f80bf332463 +checkpoint f900 fb_fnv1a64=aa56416843503b72 +checkpoint f1100 fb_fnv1a64=aa56416843503b72 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Double_Strike_Aerial_Attack_Force_USA_v1_1_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Double_Strike_Aerial_Attack_Force_USA_v1_1_Unl.snap new file mode 100644 index 00000000..5e00d2a0 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_079_NINA003_006_Double_Strike_Aerial_Attack_Force_USA_v1_1_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-079-NINA003-006/Double Strike - Aerial Attack Force (USA) (v1.1) (Unl).nes +rom_sha256=1cb3e9a99511be7ac1e79afecc47c895611691278757134a19fdeea4db737aeb +frames=1141 +fb_bytes=245760 +cycles=33949773 +audio_samples=836505 +audio_fnv1a64=338b0b3c7dcbb379 +checkpoint f900 fb_fnv1a64=c1e70a1dec1c23db +checkpoint f1100 fb_fnv1a64=8533cb84f316099a diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Armadillo_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Armadillo_Japan.snap new file mode 100644 index 00000000..b7cdf381 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Armadillo_Japan.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-118-TxSROM/Armadillo (Japan).nes +rom_sha256=80ea0154250f22f1d0d4a1098a737b57679871af956c915628bf391b5b88fb69 +frames=1500 +fb_bytes=245760 +cycles=44640975 +audio_samples=1099936 +audio_fnv1a64=61f5c31039b6abb3 +checkpoint f1500 fb_fnv1a64=e7abc296808227dd diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Goal_Two.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Goal_Two.snap new file mode 100644 index 00000000..733c5a4d --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Goal_Two.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-118-TxSROM/Goal! Two.nes +rom_sha256=dd4963d103b3a4bda2a0d46b603799ba4016f650a9fecff9c01553615454d374 +frames=700 +fb_bytes=245760 +cycles=20816573 +audio_samples=512903 +audio_fnv1a64=7c223388f626439a +checkpoint f700 fb_fnv1a64=bbd9ec6a0db4a3bb diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_NES_Play_Action_Football.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_NES_Play_Action_Football.snap new file mode 100644 index 00000000..373a082e --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_NES_Play_Action_Football.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-118-TxSROM/NES Play Action Football.nes +rom_sha256=2b03341f9febb511a0124a6e552f8bbc5295041955b5fd94a95d59175162970c +frames=700 +fb_bytes=245760 +cycles=20816571 +audio_samples=512903 +audio_fnv1a64=d189b6964169323e +checkpoint f700 fb_fnv1a64=62306352cb723810 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Pro_Sport_Hockey.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Pro_Sport_Hockey.snap new file mode 100644 index 00000000..23d89280 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_118_TxSROM_Pro_Sport_Hockey.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-118-TxSROM/Pro Sport Hockey.nes +rom_sha256=fb1c78151e5778c30d09b5a899f9a9effe02c5f7bec4e81bdb7516321388404b +frames=700 +fb_bytes=245760 +cycles=20816578 +audio_samples=512903 +audio_fnv1a64=a76823e158e8d62c +checkpoint f700 fb_fnv1a64=5f89345b72920155 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Bio_Senshi_Dan_Increaser_Tono_Tatakai_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Bio_Senshi_Dan_Increaser_Tono_Tatakai_Japan.snap new file mode 100644 index 00000000..e231d1ae --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Bio_Senshi_Dan_Increaser_Tono_Tatakai_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-140-Jaleco140/Bio Senshi Dan - Increaser Tono Tatakai (Japan).nes +rom_sha256=dd354c6777a8df67ab4f8a703e682d23108738d6d27c3559a76864be8c5298ce +frames=1141 +fb_bytes=245760 +cycles=33949774 +audio_samples=836505 +audio_fnv1a64=5e4954d73e80b9d3 +checkpoint f900 fb_fnv1a64=a5a32c3d22e5ca53 +checkpoint f1100 fb_fnv1a64=74c4f8174176961c diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Mississippi_Satsujin_Jiken_Japan_Rev_A.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Mississippi_Satsujin_Jiken_Japan_Rev_A.snap new file mode 100644 index 00000000..ba93ba91 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Mississippi_Satsujin_Jiken_Japan_Rev_A.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-140-Jaleco140/Mississippi Satsujin Jiken (Japan) (Rev A).nes +rom_sha256=b61c9bc37ec251f0836d2dc457605b428b964287aeddf31d97e3fb393ffc9797 +frames=700 +fb_bytes=245760 +cycles=20816571 +audio_samples=512903 +audio_fnv1a64=9b93b4483c8cc219 +checkpoint f700 fb_fnv1a64=b5872c5122fc5887 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Youkai_Club_Japan.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Youkai_Club_Japan.snap new file mode 100644 index 00000000..9fe2ced1 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_140_Jaleco140_Youkai_Club_Japan.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-140-Jaleco140/Youkai Club (Japan).nes +rom_sha256=8b40a27c499216f874bbce9b7b8e693c38600f33e9f5614da599d9d21b39bae4 +frames=1141 +fb_bytes=245760 +cycles=33949776 +audio_samples=836505 +audio_fnv1a64=caedadb0ce5bc1d2 +checkpoint f900 fb_fnv1a64=016331c2243e4fb3 +checkpoint f1100 fb_fnv1a64=016331c2243e4fb3 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Babel_no_Tou_Japan_Namcot_Collection.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Babel_no_Tou_Japan_Namcot_Collection.snap new file mode 100644 index 00000000..5722c9a8 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Babel_no_Tou_Japan_Namcot_Collection.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-206-Namcot118/Babel no Tou (Japan) (Namcot Collection).nes +rom_sha256=725464d462eaa7b14d15df1423a3f46897340e7ee957d98453622e9a8cd2f33e +frames=1200 +fb_bytes=245760 +cycles=35706823 +audio_samples=879798 +audio_fnv1a64=d1478d570b6b9475 +checkpoint f1200 fb_fnv1a64=644ded90761f15c1 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Dragon_Buster_II_Yami_no_Fuuin_USA_Europe_Namco_Museum_Archives_Vol_2.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Dragon_Buster_II_Yami_no_Fuuin_USA_Europe_Namco_Museum_Archives_Vol_2.snap new file mode 100644 index 00000000..a6e0bf10 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Dragon_Buster_II_Yami_no_Fuuin_USA_Europe_Namco_Museum_Archives_Vol_2.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-206-Namcot118/Dragon Buster II - Yami no Fuuin (USA, Europe) (Namco Museum Archives Vol 2).nes +rom_sha256=80e9b97ceda4862102cb7a70ecfba8fa1fd2a5f5a17b39625b583a0143d20306 +frames=1141 +fb_bytes=245760 +cycles=33949775 +audio_samples=836505 +audio_fnv1a64=e99aa954b8cdfb6b +checkpoint f900 fb_fnv1a64=b0cb39b0cf5940ff +checkpoint f1100 fb_fnv1a64=b0cb39b0cf5940ff diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Family_Circuit_Japan_Namcot_Collection.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Family_Circuit_Japan_Namcot_Collection.snap new file mode 100644 index 00000000..94f6bff7 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Family_Circuit_Japan_Namcot_Collection.snap @@ -0,0 +1,12 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-206-Namcot118/Family Circuit (Japan) (Namcot Collection).nes +rom_sha256=4b034f661dc6bb2e484385e56fc3777d29b815a9950ad7bd472056495cff9139 +frames=700 +fb_bytes=245760 +cycles=20816572 +audio_samples=512903 +audio_fnv1a64=5faf42b49fe431c3 +checkpoint f700 fb_fnv1a64=5ec4884d35d9e78d diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Karnov.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Karnov.snap new file mode 100644 index 00000000..54d1dece --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_206_Namcot118_Karnov.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-206-Namcot118/Karnov.nes +rom_sha256=828f34aad8fb07bf3db95a572d5e5e60e33f1ab3cdbbad6a90d97c6b09b846e8 +frames=1100 +fb_bytes=245760 +cycles=32728771 +audio_samples=806419 +audio_fnv1a64=f2e3ae14ef57afc9 +checkpoint f700 fb_fnv1a64=5537960191767fb2 +checkpoint f1100 fb_fnv1a64=2419807cd60774ff diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Adventure_USA_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Adventure_USA_Unl.snap new file mode 100644 index 00000000..b32618a6 --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Adventure_USA_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-232-Camerica-Quattro/Quattro Adventure (USA) (Unl).nes +rom_sha256=89aeb310d21ba82c093b95bc4c545a6c929ba0a3541fa5d4a0ff26b5b2854355 +frames=1141 +fb_bytes=245760 +cycles=33949805 +audio_samples=836506 +audio_fnv1a64=da3c45805c65bd49 +checkpoint f900 fb_fnv1a64=1719dca5cef7a325 +checkpoint f1100 fb_fnv1a64=a549da46d55ca6e5 diff --git a/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Sports_USA_Unl.snap b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Sports_USA_Unl.snap new file mode 100644 index 00000000..aa22314a --- /dev/null +++ b/crates/rustynes-test-harness/tests/snapshots/external_coverage__mapper_232_Camerica_Quattro_Quattro_Sports_USA_Unl.snap @@ -0,0 +1,13 @@ +--- +source: crates/rustynes-test-harness/tests/external_coverage.rs +expression: text +--- +rom=mapper-232-Camerica-Quattro/Quattro Sports (USA) (Unl).nes +rom_sha256=bc7fafbfb01a85996fa2c8ca57e1dbec9cbcb7458f2fcf1876d04a7e77b2f439 +frames=1141 +fb_bytes=245760 +cycles=33949791 +audio_samples=836505 +audio_fnv1a64=efcfb178ca267f52 +checkpoint f900 fb_fnv1a64=e18ee694931ba387 +checkpoint f1100 fb_fnv1a64=fb2cd2b5efdad8a0 diff --git a/screenshots/external/mapper-001-MMC1/Dragon Warrior III.png b/screenshots/external/mapper-001-MMC1/Dragon Warrior III.png new file mode 100644 index 0000000000000000000000000000000000000000..a286e99ea00ccc6267478d3c659bed01a7a17608 GIT binary patch literal 11175 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 SW$C{;+%pd6?Q86=r2Ic60F)B| literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-001-MMC1/Dragon Warrior IV.png b/screenshots/external/mapper-001-MMC1/Dragon Warrior IV.png new file mode 100644 index 0000000000000000000000000000000000000000..ed26c8480921333ff57b9d39d48fecda55deaf80 GIT binary patch literal 5343 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-011-ColorDreams/Chiller (USA) (Unl).png b/screenshots/external/mapper-011-ColorDreams/Chiller (USA) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..bb99530dd1d4146962b9de6ed8375032a9b78f0c GIT binary patch literal 4822 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 N<1yExB}e@3{};c6SF!*A literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-013-CPROM/Videomation (USA).png b/screenshots/external/mapper-013-CPROM/Videomation (USA).png index dbbf95b64f8b829fd9101059f318f28bd6a16bc9..910c65a4c968ba0582201d3687f7a7d7173b5d82 100644 GIT binary patch literal 1420 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ4fb?#45^s&_O5qPNUB7` z!#u6zvIRMgZXpZb9k|pPe6ztv_lSY8>uJqAhUGpf+|#D@+-MLkVb58x*3UvyZ_*sQ zW$WL6_Bj;KdeZ;J|F`S))n)GP-yLzLVTtzbdkhu${Xsbw#iv11{RdBeunKWpqVi@(30m-qH#y8Qf_KYy;@KNoAi zyKqQr0yt^|#ZI`V7(Ejz~n&iqK_eB{OF-DX!?f9N>>)A26 zKZ&(Av&|TLY=0cCGP8~i=lj1pThKyBdNzaJuIKfizw4?q3LkhU^G~(?|A!e2J`DMV zKdjCE$sEw)OJw@7{_vJA-}|qv{^+VP2p@><{}|ufu<`$fnGAclK1@?&;`{&5m~l-d z(~a%UCU<_nzrAnfF_r`-i-&UiPqI9)Y!E*7Z&|M|!(r<`dxQ(hJ01Et6MTga7}fnQ zFuXAPw~R33b;cw5Y*IaQ8Q$5>C^NjkUSGH6(r<=kay#nt*8i|;@UEKA{N^c3!QYoJ z4_1GAbbR`2>*wpz_1C|OzWVXB`L~$)d+WB|-+irrzTYpS&FTF9_UG5$ULXJV`?}Jn zQ4Dt zrMI44tNp;9%)d{dAj5}&&n^+@X9jG<#p(Lf^;Ks911sm@*I&8+|4SX-=fj}Ja0pKD zm7I}33=9f|oTaB5zc0<2{U9iT@8Bun25CE)zMn}OZ?Q1Q^pTO)h)BU2;0%F;YhU%fc$QC>$u4pO6}FzPelF{r5}E+sM(HU4 literal 1943 zcmb_d`&ZHj6#s%`+9q0CD^0Pdr&7~&YNjBWX&%Z)wP~54EA1%0G8GjCW+!cPOfik{g_xEWq~u&^s9@q3e4`*BDl+>6{9!x(a?d^I-gECgpY!?L&rg0nUIyDt zwgCWO;Chfm73=MB>QZva?8y1vVo#tul)=8-JXfEMsPvi+ zZJw~3##+==1QzWtFy7rhs+FJh-*F9v-9yr4Ii{t!B7_L^pXKIgD+nb73cQ2?8TtV1 zD?rZzFgge9EdbEAn^BnI+1EIHV=GTL zHIvYj9|n!v&ZUYJH?n2lE&ePpGZ&{Aj_y|u#^L)LJ2Dvw?{M&9#M+62C-RV#OxZ*H zB=xyVBYPncZyYHNt8;@)yidJPQ(6S25Uuxu`_Rr*OW0$D#n`kGnHL-5OA)!6hy4Za zFol-*uETQrzX24yRze|`I<+NYS1Y(dLSl@DXPgx&I%W`io>eJnh9E_;%-W8kly_8N zDoN5+F3{c&CK-n0$h%Z!_0vBZzk1`Yp8-ubP82 zO8S(gANOW5$2iMl|2&z|V&|74Sh9DjY`UdT9nL3AJ{ag9krZxb&by#B#FF>;dmiAD`*Qam@kBG63>zXbooKE8O0b?gn^B;Awltn z!$769De^piDS>o=!$?x&J@d@mGZDWu`Xiw_lgVTk*d6tgU zdt662WN!hIa_ZKu{9)OYVs3$UQo}^x#2p7>XmPc>VTb1<9OT-Gp9hL)t*&4)kVr3SgraS8#eVYxfsM6vI!` zucAIN0H zlQpw6aXPkARSLI}+B-$XBs=#^MibAGV*+z{OBhabIKtEmNSeP|nE(BZU}u}ds&i@6 z%8Y|1UMIL{AXg+uz#%c$CqJ0JsNWj59qdA?rqp~+fy;1#06TOs&;%73vCQOj6ZP89 zEJl3|{@(R>Bc^54D>i|Cn!?|=Lkb#=hDV`i-XhRdd>0K^_b7lxXC0Xsc~2C+#@TDF zbDw3Cecd!F$&k+7aifi1E?j$XG*8+vFz3RI=BP#_nY6++Z28!Nyo!RcNhc06>*iRP};*i4^mR+6e$_dLGTVXdV~3kSCi^DUdlxU6usswm*M zeyHv8IQ5=3HH@G7WLE@w8v*>io{RrN|V8M0>c(4C?wJ>39x;Jiej~ux<8N ztX@TCp%3)$zS$*@2S0aczX?g$;=KF;yqKe42TxQ~Y7@p0Y-4Uu6-tZo8PN%}Tejfx zQ(UR77g>@cG|z4~3|ADeW`!f5a2QzG{ETH)*m;`p*AGl433eD4}3d$ R$^WzOdY|#}sC5s?_!sr(N2ve+ diff --git a/screenshots/external/mapper-034-BNROM-NINA001/Deadly Towers.png b/screenshots/external/mapper-034-BNROM-NINA001/Deadly Towers.png new file mode 100644 index 0000000000000000000000000000000000000000..15abce45ab879dffa861883db24d26dea1ec32f8 GIT binary patch literal 7086 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 WL7?`;g$>~Fxpvvqp!lLg*#7|bAFN#f literal 6818 zcmZ`;cQl+`w|}N!Bx3Yl1|dqcgor-6=poU|Ac7DPBqGe1Az&4EbY$Rni4FCX-_YGaGTTgN~U#kv__B@PQ;Q)9EgAr&yc+a{ z84AsJy&D2Ra6+^|JNOR6#2P(vwGRYj-?{epIv5y{V>;U>Ef_#{A_2gQ3wCxI5D<{> ziU0rw6N~@=+da(G{18q$-pBaQ?A4GSl>}4e54i}$9#ns^*2+Yy5^-nvu#QllN%ST| z(m-H~>~~r&F1#WP+zYa;)Xf1ZIW_Sm1$}w)gZFGOCLFUb;?sT+c|7eEY9(-}PGua2 zIAP-~eEd#R7U!&gU;n)SktVID_XNYZ%5{w22#r*@6X@3@cpP~24o&wH1kY!&>0FXI zxvw|u{akeS$;Ucbdd>@j>gLSsE*ps%;dTfhebn)YO`Fz?)Cc@fd|+pM{?pV%o2Epi zmQNRf41y48nXpFwolEJMM9{8#FZu>5jCN&4Go7#V4|lj<1N1WlY{A^}=e6MI<3A1V z4hMwpobRV!$ZZFwjXA9D$Frg0-%P=NXKo}$kD}s*f)Z1@mjkIKL}DJ1#{TpX4!Der z+acH=DpQJnX_BYpXNoc|NK*rl0FndtbCY)&^zSow0+MMQsb~lsz((PKf202y3F~3V z6Kb@}2H=%II^Oi0hAc4y^qO;wDfJ(P9<(*60#0fFD2T$Fxk#iu; zYe~xaU*S5fYd`Kozceb7P6SRqI+UdDN%qnenVp|N{=V;S#tt`d)@Nb;g7^NYUc)VmRCyZa8vns4KGk)5@OkJyOCSO z;F2-f5dM>-slN7yG#kpS5`J{b0z=*mNNB&^Hz}EEB*~JAM%E=CLdb-0IsFb>NI>sZs?Qo-FF5kB8x}GT6NlF6Ob^ zQYQNzq?FU+KKp1faJ%sPyiUv99 zqo)2INw;P;ZpbgwMl`IgjMc+m%RZu}_(~gqGmnPYBW{sHOv%kb zat*f`Ghwy0;1aGs_7#D>{PU?ypLkAr=6eOS#1>3eYgg->*2cE{{WP2OH67!;25)9_ zV1bL4e<#S!xpBPsIn0)*c(hojZ)gtzCmWX17`c)8{`Hi_k|9JSHzI8Vxef`Rt9EblIJ(tN9uTh&{buRicOK`wVbf%k*aT&uNmJ*KtLY1n@4-A&De zYTl(xf%X*PDsIq0CiVKawlz;r%H*S^_3p8KCvs$kzOs>^jU`3po*=Qyo-BLoLBI`l z^Pgx$r^~CsM<7R~r}hQp*pG<~)RKzDOpVj&#>xjd+Drpu6`Iw`Apev6UAl0RIq}E7 z;eF6dGqMRrKVzwFv7u(%eER8bBz9L{ut+o&Y~2KRpd9!xZ;bXnwR$IIsSSHOn$vbg z+%{)03CuV6T=euvwkOZ@N1XjXU$N9Wn7eYrM>ngxU%Tv|WGY}FQ6NrQd2Glt``Nn6 z(4QfnYV?VCEN~gh(8f?L|E6xrA-;dMt|s_J$>jMa%4+1+QqO}Ra$fj!%jxpOpXQw4 zC=v*eYWE%SE)>|l{C_Jd1N8q-_Q+ePk!woa63t=|*7jtaFz4J<0u|!11tpmFolKquYFdB46ODdaKAhJ~?MJcwhcg81 ziLMN;lv6WgRQ+hR@aDpglT7OJ+@>m=>KYUeGNC-SMF0kG@Pdi)#8dR2nwN{zzJ^ni zxj*5W2P9Wj5vUkpB}nB$Py2CF4;1V#=d5;cG=Y*;JAM)E6dYnI-7N3#zAmFLfJ{=( zP+v8rV}wB$#D7cIDn4C0+8e%*Nm78=B)PAba4V4nUzIgTkocY33$TE~-&Jph%3K;F z7zj#FmUd@I59+h4X@3HsDdukW2I;UQ#noPp5V8H%Cx_VNj&^~%)9R3>5lzyJMlR_Fo@5< z7iAf>B`I)xRSNq41yBz;%h9Y1VtZzL@mJ^iM*B(Bjpf6#_(EH~aQ+}ClxqI@DlLJ1 zzpHhx#$N>}y^6n4-)gM0w&tgy6m;)okertJFB63p=T*3dp4WKYuk-5|v*;H3Ll(Vt zTBpvQ4*^Y`d!OXwei_5UDksr7E1F4S%@Wl%6rYANVe4)RG(DvGtUnsm-|wLMutc`T zr*uqCLWz1_$d(<}ZtoQ}6MmQW*IU>W@>TutwD1UiMnlS9!uS>;BwGmj4h*v83aGgR zpOk})r~$l&?(ic9-PN_!9F2Hk$Nj4w{;iWtKRS;;%D|D*O0rikIE|sTlKpQYfb&}B zKX!EUnC%&mJh|r&Pa;E<0j=ebDay{(%Z1-wy{iW_h!b6}4P6HH! zmDk`~!GxH(YYx!;)^ITM6)B*OLgf~g|&b~<4g8ZR0AS>AmB zEYu4IC%+I;HG4g1P#@8cnQlF<$-HvKZ@>ANmmxuK_ywzYjnH4SZZ4ZyqyKI5`R(4m z+~asx$DCQ>)cdBXy}++S(A>^h3f(`pEY2~vbpM`?T{tZV7efb%f;(PpPRefTS^12% zxneX3aKh&kjlebV<(sSP0RxkwBtb*408&OV0(-Ry2(wJQCv4I9Idw9yAI$tOYw_-D zI7-7gCahyJ9aas?4*~VkgaEyesWZ|#gXGeN&rtN)R?kq5Wza8H7*awm@ipBRQ4#@1 za4v%oa8yq_*gUm}rQeOrlcpmr7QM z!;)8bfyeYvMCR*dP}pWUX}S%6a^RxM*>OLa&j0#Z7=B%Zw|gFp&LH3m{z$;6dhwy} z(k|ZY5@ot>pP1^>sucF1nTZ%y>5z7T*d`LGhBawDXr}TU4Nr$&>V<3zTFFr~AhJwY zleaS_q$2s!ZU_2QRs^R|QNX8<4I5m}&OYx>{`yq^6&FLTN)V2ypfKzrIT5S@uRR3! zb9o?$bMauNS~0xO55H*x9d&$Uzamn&|wduoHQr8-Myl*jTRfTG^bP7XdA z{}mRmUPDz){0fW3Z?W%Hr7dcCA*K=ADG4U=86Zz>ELZz35(tQW=;OXGb^Tm>YQ&Q` zIZdVV?9H{pK85*}D8$DCAjLLTvXw|UU{vuaK>PY_X66OAPBwPxAfW4B6xE@%F4@icC}ISvf^p69~nDMCq*5o9(0|(kpZ! z%d3&zh3^XwV|{Mo1)ydKKwt=$@wF*G^S((@dU?wGnBQnam`T@TkBtkk%$eI}YXraI zVbWgUn7^>#P);Q26(MHF=j%|}rnx|7w_RuyP>O)9qrCcL)4$BLk(ypsKg*BXRPh@* zpnP~(IC=Cth><;mV9=qMD9>eOW~aZWEXv)dp&TyTW<&GL6OfdL&hwAPFk4Ehf}{Kv zK;r&O4J3a3zUXgZQK}dFezTAM;=B@MbN+`^<)>^8zHNXuPf{K*pyKhys+A^|-#r9k z?H2L|HXW7W#S9m9t=C5sEpPyA`Jk~6^Q7r}iV*05N5POlg&qZ^<;cCe0OtD^Mg7`1K0KG-JajTK^N&`x+FLg%!&Jh;#KM&0}R>DjtzM&Qv-Mdii_KsDA zgKfNq3y$U9HEdMtQ@JLcMQ^ZqW#B_jcpwxcyij~7gH^yBPU{)z#XpR_v|{8nsx!l= z<9aNiCiq*|O`8E5alfelmrBLg*WVBHFC1)(H9>4%abicAUb*q9O|fZr12CJSA}$ad zG30Hx(mEYvuT#utIxi7{EsTy(X?Y&cPZQ(1E~d9tLLape{1Wty`ALiyGy*#}alT4> z#3nKDPXAxenrp9t=Xe|6rQj(Sg^hK)f?{<)c!_JZXSo{|zN-EFD{v4v+`lt2Pmzi; zbI-SSW@TUrZ6d*FwSmiNM#kw8ZSal+%csDt5T_`gA)Og7C+64zuap+LBsw`2Kho~n zK3OrB^`-b^O4HAmd-hE;L4I6Rb{G=Q-GYXRX+axrnlbF9hnM+b!Uju2jPE%&_kU-h zU26<|PnW#?+xah>zbmEKH2oiyyS<#_;i5`&(V(~r8{B))dh#-8qy941o==%VWF-z8 z?y!@Ebywtlu=&c7>e@vuX=&vQEicru*&ymFJ-NOA5XsA9J<*)kMH@r)NL?&$XaW$qE<2GTKgnU{D2LXTW0+ zSRO0d-HUd*l+gDjgWidOBG6q42Kl$^VKuX_mB5=gtg%Ovovbg=GT32Aa<`=p(>LbaoIdvoJkBOPLG6K{8Sb9N|na!$itUV=62tRG?XDZjtQ%$TiX z9`%T$GuupSJLVSq(Gq3LiMap-Iip!{r)?jF{T$fn7p6KY&&Z5Pj(m!{eCFk|VxDEW z_XCsZhsG>c4~NL=g%BA32k!V|p2?ceN~B z-iDXSeC@yi%PjqOnqy}x+r_b;PfAR>-`;Y@7d(x(-T;s6x_z9j|J7n^GL*w&t2^KV zJtOL(CP29mI2kD{67(L?)zy(HRD8JNU4$~Qi*_Z9UsF#&N>=*p6aeVkOjky z7j*HBX?8=%x3rw0Lfno!>~mYoAI~w-Kv7}=)r_P&?8fTnM|7gx;;DN%557$X=V9co zgb_9g!C(4DDkglbr$y{c)Gp-==fQ6;R<15tK0EZXkLcW&2go6_9sz)(hJiBUG zv9ybg4kkK&zb9cJJa-oB7bP-_t)wrK8{g1f$t4^fk^Brs57kIwCdF(I!bu_GKv!8BNYFA`WH0h#}Gu z6+6w|lJ1xwXYlI@Hwb_kk?1OAP^nYjUxJ$Mu*>8F ziAjY^^-O`sij{4>JHgIW%Wvc2Sc0<4@#=6hoVvMo`)E}y=GMwVX1}*oF@5swn3T-x z8Z;e_)UPtczj`X2G$jSY8E19@8PJR2)}#Gxm3wdcJ}tq`8z-(~Y^c$L>X@x$`$_-@BZjR}W#(pt&> z&RSIy@a!m{0A;>)YRt0Kn&HQ6^mf!Qdqll_?(fydH(rr}fD1tzv4$eJ-S_+!FUooS zWqf!?=A(6A(phA;cp62xM@HlRM+c6SX~&7nM{6+{BuUjm)v%FOX*;rL84FHWJIJ;Z zAq4J0ot&X+NrF6#-#c-l2DD!lKBa_!tI2eGN@*SsL0K2K*s_iTOx^Iwg|{OPD30|b z=Q5daX_T6}XzzWqN1$HJJ*nD?y!xS!B#QPYF|37$K*r3^Op=Tc*Z|f&*rM6>3{i(E z+7fgtIx=SC#CBzLImaqzyVOoI+DpIAr9hGpNxq<|+>XlG@6AY>KUR_@7&19zQD$dn z7dY2!@r^L=_B!-bxp?Yl91%ZvzGJ=_=(3YAwCxvfIDoBZls*c7^BmED*;I>ZL4yo0 zjJ==x9%;(V7Ew+E8l|y~F6TL&XZ5SUV!3#X4s1zEZ&4+5yGsswf}1!-uEC%gqu+p)vUk?oz)Sf#?aP8|LC$`exik>3#H%UC)7QI`6hJ+gX*__2HN|l zkMd7(M6+YnxmycFJFzg|=uZ0fc?ROh8!aTXzQYxYN{V{m^^FMu-lz!$O}$&>MFEX; zJ~cBY?JdI%Npa!l_36I42ZsO#y%W(p<~Z2kFIP!ld%@@8&?kr8#N<4 z2bd3h1;aW$3{I(!9XHYIN44l><(0NH3da zwRU5lMm7$dvQ!Kg6__XX4M_~#q0!5=%D9>LARv(`tT}6PT5;sC%X4?y;az3fMSAS* z-6;9=fm`D9uYB-?{I4oOA026Nv2upx?5n~HDp6L@bIE}&%XJOTo;tec+u!PWGg%R$ zB){3n9ho%M>diDXri^_nYQMh6j; zN*ger(Hfx*$hU&n6P7GX*)#PI+Zo}C{D0qA{-p>W@;|=gcCDvz^TXNO6L3S%Ot(TC G74ct^g2W^M diff --git a/screenshots/external/mapper-066-GxROM/Dragon Power (USA).png b/screenshots/external/mapper-066-GxROM/Dragon Power (USA).png new file mode 100644 index 0000000000000000000000000000000000000000..6af5492d849f262fc6f3e839ea30b9b2524098e6 GIT binary patch literal 2703 zcmcgudpOj27yr(Mp%}WjW{g^u2uZw>#O%v=#)lGFzc6u|2z$>A6#MV@QZfpkn_70@M|tpm^Buzi3ba(TEGO_rm@H`VmA5d=KFQ zo!VS1@ScMo0_<=g=?6>T_rMZlk7ogR>lhw9NX0S9k9rkCftu>NShA9En?) z%*0%gVEUP1$7W5INO({rsL?A#6_96h!`*dkIFdoCj6^cek$E7o_AL!O!-?5#ha=Jk z{gNgEVTW)UTY(llGJRkFniS&3S1-Scw5Z-~6??ZBrM#?_tm$9L1wB?!a6xJRGlhP{ zVV&h6*U?kHR?tW6?taY(heA~Co$>r(k7DngN2hOhS!q<8Ju^Bo*a{q71Z7}Ot|)whZbyk!MxDw*?g zx64XD1O_x=L9G^?jf->GQz|gjil{dB5{j7dx39u>%L#f8$b#=k;8T$k4*Zs~h1aOt z9PrdvcKoOMZ+~HW+Way`dBa43m-{d?@sLb>bCHJ9ID`6DHTRLpcUC}dpGpy`ElR#` zslV=o+eiM6$*`O3o*iVzuI(Uf(*JnkbN|~5>E_x1{p0ILm|%vXSJs8psu1=zQ*9!HcIkvhg<@fxIWEKs z3bjK|`dNFZzQ#63AP-+RFmuJYY#@F%EL?$%OWS!MGlp+`n@nHh;60oue8TT`Kf{jiuNT$w%c& zHdSV3*yI~{qf(pbKyMfp&y}UIn$j6IbB$24eM*$4z(hzC8I-lb2rQpbF{FV24bC$yo8x*ze)+p&zl5IGil=PC5}k2 zgl$^Sk_y|?Ih@T|`ol#vlSVN`I@e2H@FpRjY1TxKwBRuU6OqmLo8IBeg@{SHFS9En z3E7jsf`LTLk#KjWGdi>ps?&X6=CE^IYcyfuczAf@w>aYSRpbc&To5^%GjMwOrzDt; z&JK5&n_bF!6`wW13d!7XHeosR?a~Qn(VZ9$u^GIJRAigV7nB_|B0pZ1MQG3n_2xjw zHg9m~(#E`<&5dadhc<^q8K#1|tos#vh10URvvk4I_1UA@X_1ILUuj0{39nQ!B7?mt z?O}Z!<2>02a9l+myRhxo$;c*6&~r0fsmW??-RXGc6YHl2@&PecP_plAa^rSA!>$NG zqx8tc(9h!C(MBi5+hcloEpzGg%^_OtXO@%{b@-tx6%1t|M@qs%T92}ZqU?~tf`hE9 z&4nWLj}vyp9$`@w33}xn#86v)KRrDlP?{UuK7Of}Ji7QoaOz~L^1Dv^Un)ja;s+eD zmTF%(LOyS9h9h3e!ZUALv=I(d%ppF{8f>cK6QKr|8Be^9JA!aOrs3J`}S!PoJf#?73c$ z#{<}*a6_{ZpW{^uJ%PSui!_SpY^@*sI2H63Shs%9C?j}l1bMr?=V+|y}mRO4g}|PI6%p(si(sVDbZ#o z#=vv9^U*(+LpTZwmodS9G0i`RV!lqiA^w0M9c_BU2hR|@+E3){xjj8L>0#RRh9Ij& zCu_77+eB+90f9xB8>eQzf8AK+4VI#=zSiy$#7WcjQD3ek6TAjZb@ZldNk@8Y5-JYa zzjLRqA~TNaFIyl+_}UgNIjjkxqBp;3i%xh`sz+ZDK$k)DO&`m8j=PhQ(y_R zb3kEV1c1MqU>pSV`rbO@Vw#2=($pBBwUwH$5aqh|1N5yQEy31Qlj9kE%)}V_Yko;9 zK$Va1-V<)M{GOBWw?amTBT;VtcPdCZK^V5J6IwjC1b;d?0RIF#0s4PAnvEDzM9vQt VuX^llrncryyE9HUjISwi{|2oD{pA1v literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-079-NINA003-006/Blackjack (USA) (Unl).png b/screenshots/external/mapper-079-NINA003-006/Blackjack (USA) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..119c6f9e9dcfee8286a72ebc78dda29a119cc182 GIT binary patch literal 3589 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-079-NINA003-006/Deathbots (USA) (Rev 1) (Unl).png b/screenshots/external/mapper-079-NINA003-006/Deathbots (USA) (Rev 1) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..e3ad55a0f07ffd8678c295f36c35687e9743caed GIT binary patch literal 6238 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 Awg3PC literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-118-TxSROM/Armadillo (Japan).png b/screenshots/external/mapper-118-TxSROM/Armadillo (Japan).png new file mode 100644 index 0000000000000000000000000000000000000000..7366ea43cfe21969a17dd723e66cfc6f0f765395 GIT binary patch literal 6789 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 i{O4Z#f3)a+0pTQJ!8JD4PO;P8RB&nxJ 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-118-TxSROM/Pro Sport Hockey.png b/screenshots/external/mapper-118-TxSROM/Pro Sport Hockey.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d9500b852b7dfec7221269a2e579bfbd610e3e GIT binary patch literal 5186 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 literal 4383 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 diff --git a/screenshots/external/mapper-206-Namcot118/Babel no Tou (Japan) (Namcot Collection).png b/screenshots/external/mapper-206-Namcot118/Babel no Tou (Japan) (Namcot Collection).png new file mode 100644 index 0000000000000000000000000000000000000000..c03c0619a922c3a62d9def99180be7e495612296 GIT binary patch literal 6821 zcmYj$dpwhG`2Vv*Mh>F`iW>U#aTc8v+7?PA=R+Zr>QgD9lGAKMC5b6Ya+pYTaOmWa z!=}`7$S8*pTjYG$X2UkNeII?lzt`*c&tBWT>%Q*mdOz3wzTfxtB)K@*DX!hT761T6 z`y;k)0005M0vuTx@RObSp%nl&U$VD7=n~gp##iCer%`>;0tzw-cpQwOXe*I&Y=F;T3`-TYZ z%egn%;r30fqFe%A`I9%Jpx-(0zlEhSVs^dh|O1?9Q%u~|E* zx3xG9Q!&6CWoG`79z(=Nn9qFTD-TAxomZzfB_6(5DIG1taL!qYqgDnqAph70F$N9L zye*+03NOxH5XCO~YA+xpm)^<-%m80x%=yZ@w%y(s3&;sxiFg*Z%|dS@l)y@qjqe*~ zx+4Pa|B)qm=@iY=LJBYzG6A`p@`Fk#mvQp|t=;Oq8RpvY}sfTta4r+mJ~Z+%p}62EXx1EEf|SvKDzgQdP? zn(UXqM?F+#^muKrnm$1BBz}M0{ond6kp`^$@q{tMr)kAiTqNxprmw`!y1@E5ZWldl4zSGV%5M^U-G((9Mcp+p*!Y z(Fllm%Ty(XOkBPS;XBd1HpPxUoVMiDPCE~NQ&V!{oVE*nRtm!|Cq16+9cQYY2L65W zB%xtvt5?Z$Ichr}z zqp3Zi_FV}91Mem<7z}Y_BC$3T`olHgK%42tKzSPy;|29|zmof9px?PGkotYnIj`;m zjZCHF7<~aQ=>%&VX>@p9$bMuZBQO%ZJqG!3Kj9g(lTff>d5=XstlR>22Ii3DiVnVU zm@{R99Tz;9n1-A`7Z(nkeaGA|)>>2x$)yRicQ{jsu?tOEiu+O_k90^UMo%N47sv?w zb#c}+;Q5IAxcBs$3MH1yU2*EE`bJ?<;med3zI0@0l*xjH?y(+;6sZR&NX<=9g56^M zMDH!oS>TzIwFV48Vejltjrb2b=Yr;35Tz^a*m9D}+fe}LKs47xm?(c_28b_* zOaXJmsgvC1m&ieJIohydJBsvNceAt8+NDLWyOu|e2<@#~m~SNexhz=8OYx;jlU!U& z7esGmD;^N-YM!Y|PrtC?N938?HlSjVf+eI`4Lz<%F2yD?r*Y}#D>cv7{D;$#}z{DZ-Xshl`cOxcv zyr>&23+iND-u$^-l8^P|Cqzd1)Pb0sB-+(h91MvjIq-xb#^mW-+8@4|3PJ#gR5_4q zI;r$f4rHQGy!UK)3nFw}tkGWDGtHF8L|Su7$$7SgispaJ3J)( z*XO0M<|PSdA~~}Fc@3Z(BUb8T*+&n)le&yAHSAWM-H{;)ju@|BL`x6DJLCwJ@ZEok zv~B}UK_c>ljobkE{rfMPzHqhiKs5acMrNOTk~zp-N}c_TW?5Tlb>y=nuISGY+)c@b zqaA@>*lIe^V}KH*@W;9AiX~`1V(uXlP$8Y)OkIm2y#=>jDF&y@%3jk^{PbE4yKlhW z(|hz2K?$V7Gd_s?rbqHLeNDj&{<)(Xd%r;iIYdnt+vVh(SvHS73lh~K16pr0LaR-M z^y?@hGa~W_;q?*OSxT{Zi3O|h`slfj;tEFiu9xv*JczL51DbCV^hU&boUQqs$V#1?)-d%=V`hsMil9!8*Z^KGMHt z`fuIQq&+RgXXBj%q>B+;2y2#RvfbdhW_eCpJJ+zY7uM0P!~f<|Hh=G^RtrPsT0O=p z$RjK{%8}J4+I0-#MlWtJ?~!l&BC~I__{{nO@#mmLHaEl;FxkF075N*+dPtVVW}qM) zn0O@CJ(`UNe$6)v8+Dbn4X06fKyj!M#PP4uh5?Bi4Z(5`9El9kr5T~{(8Rh~wh9Pt zM(-xVy}fP5lpHlYbh!#608Eo{%~*`akT7)P(OsLn|MRF5V&V>1%G zsrPts$ozzGSxq!>S;$(y?@ussAvez^7J(u?+x^0-cPVex_#zY?_x9uf7|RDY$Ch_n zLKG-12%0gt{JE$B=D#f1gl{}(SFqy4ZnrU1s&-w zKq*)s_@P3*o)v!E}vJ%lo!Bc`h;_WtyGk8~~tz9+j>(aP_6U zqoy~`z&_01RQrA8{BM31927DyAA=!-ld<5wn%Y_y@xOSfuUUm}Q^RraXF~)!t`Pk9 zyXA4PQ2p?16%rDJHS-#2$0~B{JZIWA5Zs0Q+~3KoQCg0#SEnIdgnE}jbB%A%YoB!U zn>N^kWrxIn`(N~%Zb~wolF%S#%@!mduw(>tLU;zcM5#N1138{oQ_`6QLRS88)qRe0 zz`mbV`*ri}&rjq#1--aCyRTG})3@Mlhyv7@2PmY!xOp`G#m$k7hk9=wgH`h+iaif& zB{iIG4kVP@l^Qd;zPj5KMVje6DI9W(ZbzWPyUb8nHXdp-4KnJw9!NbON@O@U&)1J7BoD3nXqhc0ph<-~Cu;`U&!i6V8e_&+ zw>s;c>M~cbgjc|Bw3X&WDbQ5og^T~%?@#pEpE)h+r7f*KcVyzNnPLZU_S1dROCFY7 z&C*w}i3cFIn9@Oll_a(~b4T3-{L+Ib^abfvj@|EH!kBi1T0IEG8RGkXGJ`tUjO|kH zvbS0K@XzqcW(AW@MXSAv*DR2V3A_BH4+V!F`J50(G~&!K2Hk;}_A^>p^2+a&veAk;EA_4TReY-jzKTDcoAE#co^U$y zL!hv9JmKX<7bu$4Fmp~X7fH)xP%_dsxDt>;7Dzt5gj?T*0RE!zDx21o|TsCR+uddxc*hne`o z?iQw8xfe`^vG$c8;jQ4;qPqnO&;jx!`$Py7c3A~`9pp+hHRKez9N!s(u10`r;>)bA zISR0(P)?vVa8c+;BT%3O_c;?*Es1lk{6ddxWZ76kBO=XCTJzV|oI?E*4ruqL)k>x| z(^kiA>=)B-B1u^c%CeGAEP5ZPNh&Ebc)%{HA@sGI(6F1@gAZNUf(K&z87wY4*$hbU z=s1|$<*{iXZ1$tgqQfhPBl?u2-g7#E<=Z2~7j*OAbjP>jj*BmTri@jhvO$6DxGwrl z>AWy&^zCPkp8H93^&yaClEk4skdjy0?w=>$eEj@+WM?4GB5<0n+Zla+w4}PjWyB`dG<8ZniW~+S@vGxVf}-xJdJl zpI_Z^pH_K9|hZ3E;%wm&Xg3N<47N!q3k=gVM1S4@x`6{qykGFpiSN`N}WGLwSu< zUqh7-Yyflf;+}4)UUNI@L<7G3#>JO4;{5_W=~Y2hj6SDRdM?R`e%Q|l8+ub)#IAIJ z;_4)q3RlqMluO&+;4PqYZN}$+5@c>kh2K*W1OSI1Tq&d#u}!0S%XYvSKv z#YKLYOy~GqMfF{44jTH7SncIp7H<#hH{+KVyH4klcHMh%ZxN*~8E=)T`x8T~j`s4@ z^~Cy^8vCUvGxCmE+ijox{NvnaR&pmLXqh6OTDXwTq)Li-ea|F3A=O`vBAug8j;3G9 z<#Xc}$R<(;f>o>3O0uO5(Qy=Q39fZAW^8nl$EGfP;S0Ml_=qPTi=~k}uLHOhA?5x8 zD8edMLOjuc_nKEJkB0FywQ@|iN{JJ#3vwh%#PmKfs+$6VcVXV)T}{zV(!IZ7vM+!+ z+PA}6M9G)d^wX^jH;E)zyFrRPbr)(X6MEcJdQpTD00s@PKTU>o^1X@kT3XPGV=9Nk zl)Xx%?*+hrr-3i~?etYneKoTBWJK~N?l}zb%}7HF6w%}$J&v0t?dcP&U}Bj83bgT+ zAC4YRM7}UK4-@-7e-6A}lmu+3wzb-#;4!~~VW+~rx=O=1>}+PXMZ%wXW3cQ_OB&x+ zr7U0g@XAH}c^Eui&m;W}^R5Fs*M2pVVGWQU$>@+y*`P^LCV;VDa3mG45<&XTF~@y~ z%v-X+6yjrxeDCsVr%*l(dO%1_je?@|LEhcTk4jP*6yUkmvthcnwA12Ks}uI}2VQGq zL#MD;50~EI6mWgWdOD-^GFzOx28927;{X$3#=4J@fXPFX@1l$Vz zVlLD?oEh6imn9kY^RBv?SQqX*ziq`U?CwDll7xO)lW}$#Hy)XljuiDSNu&yP5D&PUkVIkkFq+4s-fz}_G}@y}^De@igICxHz3>ij zmnY&AV#Wyglq%$OZWozthO1yVsbGVIpf=iLJuc|;Yha%wnI&AZ?52jJT`+NI zH0v_g&i)D^D*daUR8;_X0?#H4s@8YKwO~tPUKL5y+pZfLbX?)-$OY>SDWppScm>)& z{LcIbtHG61Z)oCQu0PvBG{i%TSuoO!ahclDM@`qxwHVy8%kmPyW!|(f^k0Po{C}P% z?rcD}btDM=oqvhALks3fgjl**D6HAHU;9^FC1)_cYL4tCT99`Z*DQ{#$LQuqr>40p z&zpPRF1#WN+mn#bj@ds{D{*uxCt#xHr&gBK#S^gLn^VrF(~Q?B3Xyz6t86LJ{bOR+ zQhFvnv413zA<7<{EP9)G+jyt!#!tCBmlos?uR5P+hbeblOY^^xbpL0q?75_67Z7Q6rk(SguH>{#(QfR&-C3)B**>T7$*!G zBE%U*G?9q&3*W|GfC(TNDRkTXy$xhQ9+kLVE!RlY;G_l$gmsSKOV-HF5i;0xEPG zS0w(w`{M5Sqfsi69DU8h#e_czF`q4w_%Y915p#5!TwWmU=YI0RYfj}nMW4zco!Na) zHNVMY)5zx0yS$PbZU?h&J!ckd($trLeb0WRr5Y)VY*nkiFnGPQQff|0l(Zi%mhj&v z((AuXc5}b?J9`9SSg?)KoEaS*DqzII(tHyvZ|nD|3ov1USnh7?b@UEP98v{kY(fzGnZ=$nuXCB6c4^ zW~WmCST_o^E}J%ka32!T4rKm2M5k=2L*mQ}iWkmI?1*K_ZP+^Yw9G@1>uv)~X=I}dS;a1mzhOTV&{IsPm1)fqU7bbbw} z&wm|MImp*IsE{WMfYe z^&ALRYs*(3JPig17~ML!aY} zB=zbrPO2PMD=nyLoCym1N>iJph3c~^Dx40~%=jW&GIFmX98mwP)){|7xd1V-BU8-j o1yM=c3pNgvZ=c6$n$JsNrY5{vN*G5TeEkI2A9AuSvkr*=AIZ6wW&i*H literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-206-Namcot118/Dragon Buster II - Yami no Fuuin (USA, Europe) (Namco Museum Archives Vol 2).png b/screenshots/external/mapper-206-Namcot118/Dragon Buster II - Yami no Fuuin (USA, Europe) (Namco Museum Archives Vol 2).png new file mode 100644 index 0000000000000000000000000000000000000000..8cfec212bcecfd193b44fbd278e3904648c9170c GIT binary patch literal 15831 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 literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-206-Namcot118/Karnov.png b/screenshots/external/mapper-206-Namcot118/Karnov.png new file mode 100644 index 0000000000000000000000000000000000000000..f7095f7705dad80648d563ed20ccc0ad96504ff4 GIT binary patch literal 5556 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 F{|7z#(E|Vg literal 0 HcmV?d00001 diff --git a/screenshots/external/mapper-232-Camerica-Quattro/Quattro Adventure (USA) (Unl).png b/screenshots/external/mapper-232-Camerica-Quattro/Quattro Adventure (USA) (Unl).png new file mode 100644 index 0000000000000000000000000000000000000000..fa4c87c422fde024b467bacbd72f6ac39638c589 GIT binary patch literal 8154 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 literal 0 HcmV?d00001 From 75e4e37a0e5f3a13c40cc56d2cfe1deb35baa6f0 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 02:01:37 -0400 Subject: [PATCH 057/104] build(deps): move to zip 9 and port its fallible name() (#601) Dependabot #601 bumped zip 8.6.0 -> 9.0.0 in the four manifests that use it, and the PR does not compile: zip 9 changed `ZipFile::name()` from `&str` to `ZipResult>`. This carries the manifest bump and ports the six call sites. When it errors (read from the 9.0.3 source, `types.rs:122`): the raw name is tried as UTF-8, then as CP437, and only a CP437 decode failure is an error. Each call site already treated an entry it could not open (`by_index` error) as "not a match"; an entry whose name cannot be decoded is now treated the same way, so one odd member never fails the whole archive: - hdpack.rs `hires.txt` search: `name().ok()?` skips the entry. - frontend app.rs and mobile lib.rs ROM-in-zip search, mobile `.bk2` member lookup, harness common/mod.rs: `name().is_ok_and(..)`. - app.rs display name of the chosen entry: falls back to the existing "rom" default. - hdpack.rs budget test: `.unwrap()` (test-only, names are ASCII). The lock moved to 9.0.3, not 9.0.0. 9.0.1-9.0.3 are bug-fix releases. The fixes that matter for untrusted archives are failing reads that exceed the declared size and two overflows (ZIP64 sizes, implode), so taking them is deliberate. 9.0.3 is from 2026-10-13. cargo-deny reports advisories, bans, licenses and sources ok. Verified: - clippy -D warnings: hdpack, frontend, mobile (all targets), frontend `full`, harness `test-roms,commercial-roms`. - rustynes-hdpack: 80 passed. - frontend zip tests: 3 passed. - mobile bk2 test: 1 passed. No test builds an undecodable name, because CP437 maps every byte. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 8 +++++ Cargo.lock | 5 ++-- crates/rustynes-frontend/Cargo.toml | 2 +- crates/rustynes-frontend/src/app.rs | 30 +++++++++++++------ crates/rustynes-hdpack/Cargo.toml | 2 +- crates/rustynes-hdpack/src/hdpack.rs | 6 ++-- crates/rustynes-mobile/Cargo.toml | 2 +- crates/rustynes-mobile/src/lib.rs | 28 ++++++++++------- crates/rustynes-test-harness/Cargo.toml | 2 +- .../rustynes-test-harness/tests/common/mod.rs | 14 +++++---- 10 files changed, 66 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 792c4bf4..3f2ed387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,14 @@ cycle-accurate core later replaced. rather than the umask default (usually `0o644`); a file that already exists keeps its mode, so one you widened stays widened. +### Dependencies + +- **zip 8 -> 9 (9.0.3)**, Dependabot #601. `ZipFile::name()` now returns a + `Result`, failing only for a name that decodes as neither UTF-8 nor CP437; + every ROM, HD-pack and `.bk2` lookup skips such an entry, as it already + skipped one it could not read. 9.0.1-9.0.3 fix reads past an entry's + declared size and overflows in the ZIP64 and implode paths. + ### Fixed - **FDS disk games keep their saves on Android and iOS.** The game's writes to diff --git a/Cargo.lock b/Cargo.lock index aa346f7c..46be73c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6824,14 +6824,15 @@ dependencies = [ [[package]] name = "zip" -version = "8.6.0" +version = "9.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971" dependencies = [ "crc32fast", "flate2", "indexmap", "memchr", + "nix 0.31.3", "typed-path", "zopfli", ] diff --git a/crates/rustynes-frontend/Cargo.toml b/crates/rustynes-frontend/Cargo.toml index f15eed88..33b3bf0f 100644 --- a/crates/rustynes-frontend/Cargo.toml +++ b/crates/rustynes-frontend/Cargo.toml @@ -337,7 +337,7 @@ png = "0.18" # so it reuses the already-present flate2 / miniz_oxide rather than pulling in # the AES / zstd / bzip2 extras. The wasm load path receives raw bytes from the # browser file picker, so it never needs zip. -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } # v1.8.5 — the HD-pack loader + compositor + HD audio moved to `rustynes-hdpack` # (so the mobile bridge can reach it; the core is no_std). The frontend re-exports diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 7eb1d933..04d09cec 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -211,13 +211,19 @@ fn extract_rom_from_zip(zip_bytes: &[u8]) -> Option<(String, Vec)> { const MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024; let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).ok()?; let idx = (0..archive.len()).find(|&i| { + // zip 9: an entry whose name decodes as neither UTF-8 nor CP437 is + // skipped, the same as an entry that cannot be read at all. archive.by_index(i).is_ok_and(|f| { - std::path::Path::new(f.name()).extension().is_some_and(|e| { - e.eq_ignore_ascii_case("nes") - || e.eq_ignore_ascii_case("fds") - || e.eq_ignore_ascii_case("nsf") - || e.eq_ignore_ascii_case("unf") - || e.eq_ignore_ascii_case("unif") + f.name().is_ok_and(|n| { + std::path::Path::new(n.as_ref()) + .extension() + .is_some_and(|e| { + e.eq_ignore_ascii_case("nes") + || e.eq_ignore_ascii_case("fds") + || e.eq_ignore_ascii_case("nsf") + || e.eq_ignore_ascii_case("unf") + || e.eq_ignore_ascii_case("unif") + }) }) }) })?; @@ -225,9 +231,15 @@ fn extract_rom_from_zip(zip_bytes: &[u8]) -> Option<(String, Vec)> { if file.size() > MAX_ENTRY_BYTES { return None; } - let name = std::path::Path::new(file.name()) - .file_name() - .map_or_else(|| "rom".to_string(), |n| n.to_string_lossy().into_owned()); + let name = file + .name() + .ok() + .and_then(|n| { + std::path::Path::new(n.as_ref()) + .file_name() + .map(|f| f.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| "rom".to_string()); let cap = usize::try_from(file.size()).unwrap_or(0); let mut out = Vec::with_capacity(cap); file.take(MAX_ENTRY_BYTES).read_to_end(&mut out).ok()?; diff --git a/crates/rustynes-hdpack/Cargo.toml b/crates/rustynes-hdpack/Cargo.toml index 65f439a9..a0a991ab 100644 --- a/crates/rustynes-hdpack/Cargo.toml +++ b/crates/rustynes-hdpack/Cargo.toml @@ -21,7 +21,7 @@ rustynes-ppu = { workspace = true, features = ["hd-pack"] } # Decode replacement tiles (png), load `.zip` packs (deflate only), and decode # HD-audio OGG tracks (lewton is pure-Rust, so it cross-compiles to Android). png = "0.18" -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } lewton = "0.10" [dev-dependencies] diff --git a/crates/rustynes-hdpack/src/hdpack.rs b/crates/rustynes-hdpack/src/hdpack.rs index a1d87efa..66bc396d 100644 --- a/crates/rustynes-hdpack/src/hdpack.rs +++ b/crates/rustynes-hdpack/src/hdpack.rs @@ -567,7 +567,9 @@ impl HdPack { // Find the `hires.txt` entry (allow it to live in a subfolder). let hires_name = (0..archive.len()).find_map(|i| { let e = archive.by_index(i).ok()?; - let name = e.name().to_string(); + // zip 9: `name()` fails only for a name that is neither UTF-8 nor + // CP437; such an entry cannot be `hires.txt`, so skip it. + let name = e.name().ok()?.into_owned(); Path::new(&name) .file_name() .is_some_and(|f| f.eq_ignore_ascii_case("hires.txt")) @@ -2812,7 +2814,7 @@ mod tests { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); for i in 0..archive.len() { let mut entry = archive.by_index(i).unwrap(); - let mut out = std::fs::File::create(dir.join(entry.name())).unwrap(); + let mut out = std::fs::File::create(dir.join(entry.name().unwrap().as_ref())).unwrap(); std::io::copy(&mut entry, &mut out).unwrap(); } let one = 64 * 64 * 4; diff --git a/crates/rustynes-mobile/Cargo.toml b/crates/rustynes-mobile/Cargo.toml index 287279c3..e302c48e 100644 --- a/crates/rustynes-mobile/Cargo.toml +++ b/crates/rustynes-mobile/Cargo.toml @@ -71,7 +71,7 @@ uniffi = { version = "0.32", features = ["cli"] } # v1.8.5 — accept still-compressed `.zip` ROMs (extract the first NES entry), the # same convenience the desktop has. `deflate` is the only codec real NES zips use; # default features off keeps bzip2/zstd/aes out of the mobile binary. -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "9", default-features = false, features = ["deflate"] } getrandom = "0.2" [dev-dependencies] diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index 8c4441cf..35e382ce 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -3003,14 +3003,18 @@ fn decompress_rom(bytes: Vec) -> Vec { let extracted = (|| { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).ok()?; let idx = (0..archive.len()).find(|&i| { + // zip 9: an entry whose name decodes as neither UTF-8 nor CP437 + // is skipped, the same as an unreadable entry. archive.by_index(i).is_ok_and(|e| { - std::path::Path::new(e.name()) - .extension() - .is_some_and(|ext| { - ["nes", "fds", "unf", "unif", "nsf", "nsfe"] - .iter() - .any(|k| ext.eq_ignore_ascii_case(k)) - }) + e.name().is_ok_and(|n| { + std::path::Path::new(n.as_ref()) + .extension() + .is_some_and(|ext| { + ["nes", "fds", "unf", "unif", "nsf", "nsfe"] + .iter() + .any(|k| ext.eq_ignore_ascii_case(k)) + }) + }) }) })?; let e = archive.by_index(idx).ok()?; @@ -3047,10 +3051,12 @@ fn read_bk2_members(bytes: &[u8]) -> Result<(String, String), MobileError> { let idx = (0..archive.len()) .find(|&i| { archive.by_index(i).is_ok_and(|e| { - std::path::Path::new(e.name()) - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.eq_ignore_ascii_case(wanted)) + e.name().is_ok_and(|name| { + std::path::Path::new(name.as_ref()) + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.eq_ignore_ascii_case(wanted)) + }) }) }) .ok_or_else(|| err(format!(".bk2 is missing '{wanted}'")))?; diff --git a/crates/rustynes-test-harness/Cargo.toml b/crates/rustynes-test-harness/Cargo.toml index a15867af..d28941fc 100644 --- a/crates/rustynes-test-harness/Cargo.toml +++ b/crates/rustynes-test-harness/Cargo.toml @@ -79,7 +79,7 @@ png = { version = "0.18", optional = true } # `extract_rom_from_zip`. Native-only (the harness is never compiled for wasm); # `deflate`-only to match the frontend's feature set. `.7z` is handled via the # `7z` CLI (no Rust dep), matching `scripts/coverage/coverage.py`. -zip = { version = "8", default-features = false, features = ["deflate"], optional = true } +zip = { version = "9", default-features = false, features = ["deflate"], optional = true } [dev-dependencies] insta.workspace = true diff --git a/crates/rustynes-test-harness/tests/common/mod.rs b/crates/rustynes-test-harness/tests/common/mod.rs index bc9b9dca..d7f7f2f4 100644 --- a/crates/rustynes-test-harness/tests/common/mod.rs +++ b/crates/rustynes-test-harness/tests/common/mod.rs @@ -516,11 +516,15 @@ pub mod external { let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).ok()?; let idx = (0..archive.len()).find(|&i| { archive.by_index(i).is_ok_and(|f| { - std::path::Path::new(f.name()).extension().is_some_and(|e| { - e.eq_ignore_ascii_case("nes") - || e.eq_ignore_ascii_case("fds") - || e.eq_ignore_ascii_case("unf") - || e.eq_ignore_ascii_case("unif") + f.name().is_ok_and(|n| { + std::path::Path::new(n.as_ref()) + .extension() + .is_some_and(|e| { + e.eq_ignore_ascii_case("nes") + || e.eq_ignore_ascii_case("fds") + || e.eq_ignore_ascii_case("unf") + || e.eq_ignore_ascii_case("unif") + }) }) }) })?; From adaadc441b3fec820afd2737881186bcbfad62e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:09:52 +0000 Subject: [PATCH 058/104] chore(ci): bump taiki-e/install-action from 2.87.26 to 2.87.27 Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.87.26 to 2.87.27. - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/v2.87.26...v2.87.27) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.87.27 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] (cherry picked from commit d10000fa41d93dec45378e4dde4afa5d22c870b0) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/security.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c0ac339..8689e02d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1065,7 +1065,7 @@ jobs: toolchain: nightly apt: "false" cache-key: fuzz - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-fuzz - name: Build and run every fuzz target @@ -1129,7 +1129,7 @@ jobs: with: components: llvm-tools-preview cache-key: coverage - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-llvm-cov - run: cargo llvm-cov --workspace --lib --summary-only --fail-under-lines 70 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 42dc496c..3159fcf6 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -76,7 +76,7 @@ jobs: # Reason 2 was an ACTIVE constraint until the v1.3.0 pin bump (1.86 vs # cargo-audit's 1.88); it is slack today and can bind again after an MSRV # change in either direction, so it is kept rather than deleted as history. - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-audit - run: cargo audit @@ -92,7 +92,7 @@ jobs: # Prebuilt binary for the same two reasons as the audit job above: it is # much faster than compiling cargo-deny, and it keeps the security gate # independent of this repo's rustc pin. Policy lives in `deny.toml`. - - uses: taiki-e/install-action@v2.87.26 + - uses: taiki-e/install-action@v2.87.27 with: tool: cargo-deny - run: cargo deny check From 3d6b983cd2859df21238695173c1a61c0bc0b1c6 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 02:03:02 -0400 Subject: [PATCH 059/104] build(deps): bump cc from 1.6.0 to 1.7.0 (#600) The same lock change as Dependabot #600, re-resolved on this branch with `cargo update -p cc --precise 1.7.0` rather than cherry-picked. cc builds the vendored C of rcheevos and Lua. The re-resolve also moved tempfile's getrandom from 0.4.3 to 0.3.4. tempfile 3.27 accepts `>=0.3.0, <0.5`, and both versions were already in the lock. tempfile is only a dev-dependency here. Verified: - clippy -D warnings on rustynes-frontend `full`, which compiles both C builds, is clean. - cargo-deny reports advisories, bans, licenses and sources ok. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46be73c8..0cb0294e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,9 +608,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" dependencies = [ "find-msvc-tools", "jobserver", @@ -5031,7 +5031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", "windows-sys 0.61.2", From 5ec5f22e4df7cad7dc9f0c83a339fb12f4581ffb Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 02:13:55 -0400 Subject: [PATCH 060/104] build(android): Gradle wrapper 9.8.1, LF gradlew.bat kept (#602) Takes Dependabot #602's new gradle-wrapper.jar and properties. Both checksums were read from services.gradle.org, not from the PR: - gradle-9.8.1-bin.zip.sha256 = dce76f55...dc01f, the distributionSha256Sum the PR sets. - gradle-9.8.1-wrapper.jar.sha256 = 3b8a2577...68e98, which equals the sha256 of the PR's jar. Two changes from the PR: - gradlew.bat is left alone. The PR rewrote all 112 lines as CRLF; with the CRs stripped its content is byte-identical to ours, and the tree keeps it LF, so the rewrite carried no change. - The comment above distributionSha256Sum still named the 9.8.0 checksum file it was fetched from; it now names 9.8.1's. Verified locally under an 8G memory scope: - `./gradlew --version` reports Gradle 9.8.1. - `:app:compileFossDebugKotlin :app:compilePlayDebugKotlin :app:testFossDebugUnitTest`: BUILD SUCCESSFUL in 8m 28s, with the Compose 2.4.21 change from the next commit present in the tree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- android/gradle/wrapper/gradle-wrapper.jar | Bin 47623 -> 48749 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 5097068a8d375f5bf0d15693fb5b3615c972a801..9fa7f288283f81eec4952784c271abd7e0938dee 100644 GIT binary patch delta 39361 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 G?0*2IFc^CP delta 38292 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 dFCnI4R Date: Wed, 14 Oct 2026 02:13:55 -0400 Subject: [PATCH 061/104] build(android): Compose compiler plugin 2.4.21 (#603) Dependabot #603's one-line bump, plus the evidence comment above it, which cited only the 2.4.20 build. CI's `Gradle bundle foss+play release` job is continue-on-error, so its check mark proves nothing. Its log on run 38351815675 (job 115148328772, the PR head 23a4020b) was read and shows `:app:bundleFossRelease`, `:app:bundlePlayRelease` and "BUILD SUCCESSFUL in 11m 52s". The comment now records that, against AGP 9.4.1. Also verified locally: both debug flavours compile and the foss unit tests pass (see the wrapper commit). CHANGELOG: one Dependencies line covering #599, #600, #602 and #603. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 4 ++++ android/build.gradle.kts | 8 +++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f2ed387..31cd362b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,10 @@ cycle-accurate core later replaced. every ROM, HD-pack and `.bk2` lookup skips such an entry, as it already skipped one it could not read. 9.0.1-9.0.3 fix reads past an entry's declared size and overflows in the ZIP64 and implode paths. +- **cc 1.7.0** (#600), **taiki-e/install-action 2.87.27** (#599), the + **Gradle wrapper 9.8.1** (#602; distribution and wrapper-jar SHA-256 both + checked against services.gradle.org) and the **Compose compiler plugin + 2.4.21** (#603) for the Android app. ### Fixed diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 18e992e8..3e85a5bd 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -52,7 +52,7 @@ plugins { // The Compose compiler plugin tracks the Kotlin line AGP builds against, and // it is the only Kotlin version coordinate this build sets (see the header). // Moved 2.3.10 -> 2.3.21 within the same line at the v2.6.3 refresh. - // v2.6.19: 2.3.21 -> 2.4.20 (Dependabot). The constraint people reach for + // v2.6.19: 2.3.21 -> 2.4.20, v3.2.0: -> 2.4.21 (both Dependabot). The constraint people reach for // here -- "the Compose compiler plugin must equal the Kotlin compiler // version" -- is about Kotlin and is NOT read off AGP's POM coordinate; see // the header. What the POMs do say, checked directly: each @@ -68,12 +68,14 @@ plugins { // back **BUILD SUCCESSFUL in 17m 41s** on run 34694004147 with this plugin // at 2.4.20 and AGP at 9.4.0 -- both `:app:bundleFossRelease` and // `:app:bundlePlayRelease` produced their AAB, the Compose mapping tasks - // ran, and nothing reported a Kotlin/Compose incompatibility. + // ran, and nothing reported a Kotlin/Compose incompatibility. At 2.4.21 + // (AGP 9.4.1) the same job's log reads BUILD SUCCESSFUL in 11m 52s with + // both bundle tasks run (run 38351815675, Dependabot #603). // // Read that job's LOG, never its check mark: it is `continue-on-error: true` // (the name says "best-effort packaging"), so a broken Android build leaves // the PR green. That is the standing hazard here, not this version. - id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false // v1.8.8 "Atlas" (Workstream J): the Baseline Profile Gradle plugin. The plan // named 1.4.1, but that stable line predates AGP 9 and its module-type guard // rejects an AGP-9.x `com.android.application` module ("not a supported android From f3fcfd2316b70f5da51c0d4ccfb045e2c3abd7e3 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 02:58:12 -0400 Subject: [PATCH 062/104] docs(audits): re-derive the seven Round 2 RTL findings; six refuted The ledger had RTL-R2-01..07 DEFERRED to the sibling integration, with the instruction to re-derive each in simulation before any change, because this audit lane's RTL claims have been refuted before. They now carry verdicts, each with the measurement behind it: - 01 REJECTED. The DC-blocker overflow. Ran a bit-exact Python model of the recurrence over 3,000 adversarial sequences of 20,000 samples. `|y_next|` peaked at 2,147,418,112 (a bare full-scale step), never past INT32_MAX. The precondition, y near +SAT_HI with x_prev = 0, is unreachable. This is the second such claim refuted. - 02 DEFERRED. Real as a cost: vram is 16,384 register bits, absent from the RAM summary. It has three read sites, and the proposed fix adds read latency to a cycle-accurate PPU, so it waits for a later release. - 03 REJECTED as a timing defect. Its "24 ns" quotes the comment that describes the fixed crossing. - 04 REJECTED. Its counter assumes download order. The split it audits was ADOPTED (sibling 17b1dfa, -1,004 ALUTs, measured). - 05 REJECTED. One PLL at 4:1, in the same clock group, so the crossing is analysed. - 06 REJECTED as a defect. "26 vs 24" is the arbiter's own stress figure. The sdram-arb-gate re-run on the merged tree reprints it. - 07 REJECTED. The rewrite is the same Boolean function. Counts move REJECTED 4 -> 10 and DEFERRED 7 -> 1, still 61. The audits README's Round 2 calibration replaces "not yet re-derived" with the outcome. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- docs/audits/README.md | 8 +++++++- docs/audits/v3.2.0-round2-audit-disposition.md | 18 +++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/audits/README.md b/docs/audits/README.md index 0dc3d6c3..a5256031 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -91,7 +91,13 @@ among them a 47% audio regression, open bus broken on two mappers, MMC3 bank masking, a netplay message that was sent and never decoded, two threads stepping one core, a heap cap that never fires, and FDS saves that changed a game's identity. Two of its self-certifying claims were tested and are false. -The seven RTL findings are not yet re-derived. +The seven RTL findings, re-derived in simulation and synthesis, kept the RTL +lane's record: one real cost (the nametable is registers, not block RAM; +deferred), and six refuted, among them a second DC-blocker overflow, a +"clock-domain crossing" between two related, analysed clocks, and a latency +"miss" that was the project's own stress figure for a coincidence the +console never produces. The palette split one of them audited was measured +and adopted (-1,004 ALUTs). ## What these reports are diff --git a/docs/audits/v3.2.0-round2-audit-disposition.md b/docs/audits/v3.2.0-round2-audit-disposition.md index 6204cd72..79a8970b 100644 --- a/docs/audits/v3.2.0-round2-audit-disposition.md +++ b/docs/audits/v3.2.0-round2-audit-disposition.md @@ -122,13 +122,13 @@ clips, casting and the netplay picture read that frame. | ID | Verdict | What was done | | --- | --- | --- | -| RTL-R2-01 | DEFERRED | To the sibling integration (consolidation plan, Step 5): re-derive in simulation first. Earlier RTL audits had a DC-blocker overflow claim refuted. | -| RTL-R2-02 | DEFERRED | Same. | -| RTL-R2-03 | DEFERRED | Same. | -| RTL-R2-04 | DEFERRED | Same; it audits an uncommitted palette split (salvaged, evaluated in Step 5). | -| RTL-R2-05 | DEFERRED | Same. | -| RTL-R2-06 | DEFERRED | Same. | -| RTL-R2-07 | DEFERRED | Same. | +| RTL-R2-01 | REJECTED | Re-derived with a bit-exact model of `audio_dc_block.sv`'s recurrence over 3,000 adversarial sequences (full-scale toggles, random levels, square waves, long DC then a full step): `y_next` peaked at 2,147,418,112, a bare full-scale step from rest, 65,535 below `INT32_MAX`. The overflow needs `y` near `+SAT_HI` while `x_prev` is 0, and getting `x_prev` back to 0 after any rise pulls `y` down by the same amount; the filter's tracked level `x - y` never went below 0. The second DC-blocker overflow claim refuted, after the earlier RTL audit's. | +| RTL-R2-02 | DEFERRED | Real as a cost, not a defect. `ppu2c02`'s `vram [0:2047]` is absent from the synthesis RAM summary (where `wram`'s identical 2048 x 8 array is M10K), so the nametable is 16,384 register bits and their mux. It has three read sites (`nt_byte`, `at_bits`, `read_buffer`) and a write, more ports than an M10K block, and the proposed module adds a cycle of read latency to a cycle-accurate PPU. The fit closes with 44% of ALMs free; a ticket for a later release, with the whole ladder. | +| RTL-R2-03 | REJECTED | As a timing defect. The three `lpm_divide` instances exist, but in the console domain (46.6 ns); the "fails by 24 ns" is quoted from `cart.sv`'s account of the crossing that was then made from a register, and the v3.1.0 off-die compile met timing. A power-of-two bypass would only save area. | +| RTL-R2-04 | REJECTED | The palette split it audits was measured and ADOPTED in the sibling (`17b1dfa`): `quartus_map` on one tree, palette 2,390 -> 1,386 combinational ALUTs, design ALM estimate 30,397 -> 29,882, `make palette-gate` PASS and a channel-swap mutant caught (371 of 1,536). Its proposal, a channel counter reset at byte 0 in place of `% 3` / `/ 3`, was not taken: the divider is about 74 ALUTs, and the counter makes the write port depend on the download arriving in order from byte 0, which nothing checks. | +| RTL-R2-05 | REJECTED | `clk_sys` and `clk_sdram` are outputs of one PLL at exactly 4:1 (ADR 0039), in the same `set_clock_groups -exclusive` group of `sys/sys_top.sdc`, so the crossing is ANALYSED (`RustyNES.sdc` says why; v2.6.13's -24.769 ns on it was visible only because it is), and `chr_addr` is published from a register (`chr_flat_o`). A partially toggled address is what the setup check rules out. | +| RTL-R2-06 | REJECTED | As a defect. "26 cycles against 24" is the project's own stress-model figure from `sdram_arbiter.sv`'s table, which issues a CHR and a PRG request on the same cycle; the console never does (0 coincidences in 15 M SDRAM cycles) and its CPU path meets at 24 of 24. `make -C tb sdram-arb-gate` on the merged tree reprints the same numbers (26; 14 with CHR silent; PASS over 211,200 cycles). The handshake bypass would change a gated controller for a coincidence that does not occur. | +| RTL-R2-07 | REJECTED | The "banked" `oam_read` reads the same 256-entry array at the same index in the same cycle: the same Boolean function, so synthesis builds the same mux. Its "LEs" are not a Cyclone V unit. | ## Security @@ -151,9 +151,9 @@ clips, casting and the netplay picture read that frame. | FIXED | 14 | | CHANGED, UNVERIFIED | 1 (IOS-R2-01) | | REVERTED | 4 | -| REJECTED | 4 | +| REJECTED | 10 | | UNTRIAGED | 11 | -| DEFERRED | 7 (RTL) | +| DEFERRED | 1 (RTL-R2-02) | | **Total** | **61** | One row per finding, counted from the tables above. LIB-R2-02 is counted as From 3f8a5552a74de20979274002b27896785fdacbcb Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 03:09:14 -0400 Subject: [PATCH 063/104] feat(cosim): commit the sprite-limit exporter flags the MiSTer goldens need `--sprite-limit-off` and `--fb-every-frame` for nes_golden_export. The MiSTer core's sprite-limit option (FB-16) is gated against three goldens exported with them: ppusprlim081, ppusprlim082 and mapper4mmc3sprlim083. The sprites agent wrote this change on 2026-10-08 in its worktree's untracked `.oracle-pinned/` clone and exported the goldens from it. It was never committed. The clone was removed with that worktree on 2026-10-14 (`git worktree remove --force`), and the change survived only as a copy in the gitignored salvaged/tmp-scratch/f63c2db0/v320/ (two identical copies, from the 2026-10-09 /tmp salvage). It surfaced when a VERIFY=1 golden run against the new pin stopped at mapper4mmc3sprlim083 with "usage". The sibling's golden-recipes.tsv had recorded that the pin lacked these flags. The patch is applied as written, with three changes: - Merged with the item-8 device flags (ac10b03f), which touched the same struct, usage text, parser, Args initialisers and `main`; all seven conflicts keep both. - The manifest line is written only when the option is set, as `sprite_limit_off = true`. The patch wrote it on every run. That would have added a line to every manifest, against the device lines' rule that an unset option leaves a manifest byte-identical. It is also exactly the line the three committed manifests carry, and the only thing the sibling's Makefile reads (`= true`). - `set_sprite_limit_disabled` moved from `main` into `attach_devices`, which already applies run configuration before the first cycle. This keeps `main` under clippy's 100-line limit. Verified: - `cargo clippy --all-targets --all-features -D warnings`: clean. - `cargo test`: 59 passed. - The full VERIFY=1 against a pin carrying this commit is the next step. It regenerates every recipe, including the three that failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../src/bin/nes_golden_export.rs | 88 ++++++++++++++++++- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs index 00f834e3..d8feba0c 100644 --- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs +++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs @@ -103,6 +103,22 @@ struct Args { /// v3.2.0 (item 8) -- the input devices and cheat codes attached for the /// whole run. See [`DeviceArgs`]. devices: DeviceArgs, + /// v3.2.0 — `--sprite-limit-off`: run with the v3.1.0 "Disable + /// 8-sprite-per-scanline limit" option ON (`Nes::set_sprite_limit_disabled`). + /// + /// The `MiSTer` core gains the same option (its FB-16), and the only way to + /// check a display option is to export the oracle's picture with it set. + /// It is render-only (`docs/ppu-2c02.md`), so every CPU-visible artifact of + /// such a run must be byte-identical to the same run without it; only the + /// framebuffer differs. Recorded in the manifest when set (as + /// `sprite_limit_off = true`), so a golden says which picture it is. + sprite_limit_off: bool, + /// v3.2.0 — `--fb-every-frame`: also write `.index_fb_frames.bin`, + /// the index framebuffer after EACH frame of the run, concatenated + /// (frames x 256 x 240 LE `u16`). The single `index_fb.bin` is the last + /// frame only, so a display option that is right on the last frame and + /// wrong on the others would pass a one-frame gate. + fb_every_frame: bool, } /// v3.2.0 (item 8): the STIMULUS the `RustyNES_MiSTer` input and cheat gates @@ -249,11 +265,18 @@ fn apply_devices(o: &mut Oracle, d: &DeviceArgs) -> Result<(), String> { Ok(()) } -/// Validate the device request, then attach it, or exit with the reason. +/// Validate the device request, then attach it, or exit with the reason; and +/// set `--sprite-limit-off`. /// /// One call in `main`, which sits at the pedantic line limit, for the same -/// reason [`arm_traces`] is one call. +/// reason [`arm_traces`] is one call. Both run before the first cycle. The +/// sprite option is configuration, carried across a power cycle, and +/// render-only, so setting it here changes the picture and nothing else (no +/// work RAM byte, no bus cycle). fn attach_devices(o: &mut Oracle, args: &Args) { + if args.sprite_limit_off { + o.nes_mut().set_sprite_limit_disabled(true); + } if let Some(why) = device_error(args) { eprintln!("{why}"); usage(); @@ -324,7 +347,7 @@ fn usage() -> ! { \x20 [--fetch-trace CAP] [--apu-trace CAP] [--checkpoint-interval N]\n\ \x20 [--inject-instructions N] [--inject-hold N]\n\ \x20 [--inject-nmi-at N] [--inject-irq-at N]\n\ - \x20 [--press-start A:B]\n\ + \x20 [--press-start A:B] [--sprite-limit-off] [--fb-every-frame]\n\ \x20 [--four-score P1:P2:P3:P4] [--vaus POS:FIRE]\n\ \x20 [--genie CODE]...\n\ \x20 [--ppu-state-trace CAP --pst-frames A:B\n\ @@ -384,6 +407,7 @@ fn parse_args() -> Args { let (mut inject_nmi_at, mut inject_irq_at) = (None, None); let mut press_start: Option<(u64, u64)> = None; let mut devices = DeviceArgs::default(); + let (mut sprite_limit_off, mut fb_every_frame) = (false, false); let mut i = 0; // Every value-taking arm below advances `i` by TWO, not one: this loop has @@ -525,6 +549,15 @@ fn parse_args() -> Args { devices.genie.push(need(i).clone()); i += 2; } + // The two flags that take no value advance `i` by ONE. + "--sprite-limit-off" => { + sprite_limit_off = true; + i += 1; + } + "--fb-every-frame" => { + fb_every_frame = true; + i += 1; + } "--checkpoint-interval" => { checkpoint_interval = need(i).parse().unwrap_or_else(|_| usage()); if checkpoint_interval == 0 { @@ -571,6 +604,8 @@ fn parse_args() -> Args { inject_irq_at, inject_hold, devices, + sprite_limit_off, + fb_every_frame, } } @@ -1090,6 +1125,36 @@ fn run_frames_with_optional_press( calls } +/// Advance the run one frame at a time, writing the index framebuffer after +/// each frame to `.index_fb_frames.bin` (v3.2.0, `--fb-every-frame`). +/// +/// The same `run_frame()` calls `advance_frames(total)` would make, split at +/// each frame boundary, so the run and every other artifact are unchanged. +/// Refused with `--press-start` rather than combined with it: no recipe needs +/// both, and an unexercised combination is a place a golden goes quietly wrong. +fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 { + if args.press_start.is_some() { + eprintln!("--fb-every-frame is not supported together with --press-start"); + usage(); + } + let mut calls = 0u64; + let mut bytes = Vec::with_capacity(INDEX_FB_LEN * 2 * args.frames as usize); + for _ in 0..args.frames { + calls += o.advance_frames(1); + let fb = o.nes().index_framebuffer(); + assert_eq!( + fb.len(), + INDEX_FB_LEN, + "unexpected index framebuffer length" + ); + for px in fb { + bytes.extend_from_slice(&px.to_le_bytes()); + } + } + write(&suffixed(base, "index_fb_frames.bin"), &bytes); + calls +} + fn main() { let args = parse_args(); let rom = @@ -1137,6 +1202,8 @@ fn main() { args.inject_irq_at, args.inject_hold, ) + } else if args.fb_every_frame { + run_frames_capturing_each(&mut o, &args, &args.out.join(&stem)) } else { run_frames_with_optional_press(&mut o, u64::from(args.frames), args.press_start) }; @@ -1212,7 +1279,8 @@ fn main() { ram_len = {}\n\ ckpt_interval= {}\n\ ckpt_count = {}\n\ - obs_count = {}\n", + obs_count = {}\n\ + {}", args.rom.display(), sha256_hex(&rom), args.seed, @@ -1224,6 +1292,14 @@ fn main() { args.checkpoint_interval, checkpoint_count, observable_count, + // Written only when set, as the device lines are, so every manifest + // from a run without the option stays byte-identical to the + // exporter before it; `RustyNES_MiSTer`'s Makefile looks for `true`. + if args.sprite_limit_off { + "sprite_limit_off = true\n" + } else { + "" + }, ); write(&suffixed(&base, "manifest.txt"), manifest.as_bytes()); println!("done; {cycles} CPU cycles simulated"); @@ -1293,6 +1369,8 @@ mod tests { inject_irq_at: None, inject_hold: 1, devices: super::DeviceArgs::default(), + sprite_limit_off: false, + fb_every_frame: false, }; let mut args = base(); @@ -1445,6 +1523,8 @@ mod device_tests { inject_irq_at: None, inject_hold: 1, devices, + sprite_limit_off: false, + fb_every_frame: false, } } From 89df4ef53911c3a6c01a8b75bfab3c72b5dd114d Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 03:16:21 -0400 Subject: [PATCH 064/104] docs(changelog): complete v3.2.0's Unreleased from both branch logs Written from `git log` of the oracle (c922ed40..HEAD plus the misc merge's eight commits) and the sibling (v3.1.0..feat/v3.2.0, 17 commits), not from memory. Added to [Unreleased]: - Breaking: the epoch-4 line also names the VRC4 IRQ fix, which the epoch row in hardware_options.rs already listed. - Added: the ten misc families (6/8/17, 158, 172/173, 207, 235, 252 Curated; 230 BestEffort) and the exporter's device and display flags. - Changed: the tier line said "121 Curated and 26 BestEffort", which was true when D23 landed. It now gives the release's split, 208 families = 51 / 130 / 27, counted from `mapper_tier()` by a probe, against v3.1.0's 191 = 51 / 109 / 31. The arithmetic holds: +16 Curated and +1 BestEffort from the new families, then 5 promotions. - Fixed: the VRC4 IRQ mode bit (7a437805), with its symptom. - Performance: the APU numbers measured in docs/performance.md. The other imported speed-ups (PPU, mapper offsets) are not claimed here. They have no A/B against the pre-import tree yet; it runs on a quiet host after the ladders, and its result goes here then. - The MiSTer core: the two options, sixteen boards (twelve with a commercial frame compared, per docs/rung7-mappers.md), three input devices, the palette split, the pin move, and the off-die CHR-RAM read-back known issue. Plus "No hardware has run any bitstream." Three claims first drafted from memory were checked and corrected before this commit: the board count (twenty -> sixteen), "one commercial game per board" (twelve of sixteen), and the reason the sprite option is on-die only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31cd362b..e38aa881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,8 @@ cycle-accurate core later replaced. ### Breaking - **Movies and netplay from v3.1.0 are refused.** `EMULATION_EPOCH` rises - from 3 to 4: the mapper 290, 24C02, VRC2 and *Fire Hawk* fixes below change - what the bus sees. A v3.1.x peer or movie is refused as another emulator + from 3 to 4: the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes + below change what the bus sees. A v3.1.x peer or movie is refused as another emulator version, naming its epoch. Save states are not affected by the epoch. - **Netplay protocol 8 (`"RNE8"`).** Lost inputs are resent as one datagram per run of frames instead of one per frame. A v3.1.x peer cannot read that @@ -49,6 +49,19 @@ cycle-accurate core later replaced. (Sugar Softec), 197, 165 (the Chinese *Fire Emblem* ports) and 116 (SOMARI-P, which switches between VRC2, MMC3 and MMC1). All Curated, each booted from real dumps. +- **Ten more families, each written from its NESdev page**: 6, 8 and 17 (Front + Fareast copier-disk games on the Super Magic Card), 158 (Tengen's RAMBO-1 + with CHR A17 on the nametable line, *Alien Syndrome*), 172 and 173 (TXC + JV001 / 05-00002-010), 207 (Taito X1-005 with the same rewiring, *Fudou + Myouou Den*), 235 (the *Golden Game* address-latch multicarts) and 252 + (Waixing's VRC4 clone, *San Guo Zhi*) are Curated; 230 (the 22-in-1 whose + RESET button switches chips) is BestEffort, because its page leaves a + choice the boot does not settle (ADR 0011 amendment). +- **The golden exporter takes devices and display options**: + `nes_golden_export` gains `--four-score`, `--vaus` and `--genie` for the + MiSTer core's input gates, and `--sprite-limit-off` and `--fb-every-frame` + for its sprite-limit gate. A run without them writes the same artifacts and + manifests as before. - **The coverage sweep boots Famicom-only carts as a Famicom.** Ten dumps listed as blank since v2.9.6 write the PPU during the front-loading NES's power-on warm-up, which ignores those writes; on the Famicom model they @@ -59,8 +72,9 @@ cycle-accurate core later replaced. ### Changed - **Mappers 47, 121, 154, 191 and 243 are Curated**, on local-dump evidence - recorded in `docs/compatibility.md` (maintainer decision D23). The tiers - are now 51 Core, 121 Curated and 26 BestEffort. + recorded in `docs/compatibility.md` (maintainer decision D23). With the 17 + families added in this release, the 208 families are 51 Core, 130 Curated + and 27 BestEffort (191 families, 51 / 109 / 31, at v3.1.0). - **Libretro save states are about 16 KB instead of 260 KB.** The core no longer stores the 240 KiB framebuffer in a state (`retro_serialize`); the next frame repaints it before anything reads it, so run-ahead, rewind @@ -83,6 +97,42 @@ cycle-accurate core later replaced. checked against services.gradle.org) and the **Compose compiler plugin 2.4.21** (#603) for the Android app. +### Performance + +- **The APU mixes about 2-6% faster when a cartridge adds audio, and 2-3% + faster without**, byte-identical, measured in two independent A/B runs + (`docs/performance.md`, "v3.2.0 APU"). The change came with an AI-made + remediation branch that also made the expansion-audio path 47% SLOWER; the + cause, a reshaped ring write, was bisected and left out. + +### The MiSTer core (`RustyNES_MiSTer`) + +- **Two more core options, as on the desktop.** "Sprites per line" draws the + sprites past the eighth on a line, matching the emulator's option frame by + frame (on-die build only: the extra sprites' pattern fetch is a second read + of the on-die CHR memory, which the off-die build does not have; a gate + checks it stays absent there). "Load Palette" takes a `.pal` file and applies the + emulator's emphasis model to it. +- **Sixteen more boards**: UN1ROM (94), Nichibutsu UNROM (180), CPROM (13), J87 + (87), CNROM 185, GxROM (66), Jaleco JF-11/14 (140), Color Dreams (11), + NINA-03/06 (79), Camerica 71 (including *Fire Hawk*) and 232, mapper 34 + (BNROM and NINA-001), Namco 108 (206), TxSROM (118), TQROM (119) and MMC1 + SUROM (512 KiB, off-die). The NES 2.0 submapper now reaches the cartridge. + Each board is gated against the emulator, and for twelve of them a + commercial game's frame is compared too (`docs/rung7-mappers.md`). +- **Three input devices**: the Four Score, the Arkanoid (Vaus) controller and + Game Genie cheats, gated against goldens exported with the same device. +- **The custom palette uses 1,004 fewer combinational ALUTs** (2,390 -> + 1,386 in synthesis), stored as three per-channel tables. Seven RTL findings from the + same AI audit were re-derived; one is a real cost (the nametable is in + registers) and is deferred, six did not hold. +- **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems + reproduce byte for byte from it. +- **Known issue (off-die build):** a `$2007` read of CHR-RAM soon after a + write returns `$00` in simulation. It predates this release, is an expected + failure in the off-die ladder, and the on-die build is right. +- **No hardware has run any bitstream.** + ### Fixed - **FDS disk games keep their saves on Android and iOS.** The game's writes to @@ -99,6 +149,12 @@ cycle-accurate core later replaced. 16 and 157), so a random read returns the byte asked for instead of byte `$00`. On the Datach this was the difference between booting and reformatting the save at every power-on. +- **The VRC4's IRQ runs in the mode the game asks for** (mappers 21, 23 and + 25). Bit 2 of its IRQ control register was read inverted: nesdev's "VRC IRQ" + gives 1 = cycle mode, 0 = scanline mode, as the VRC6 and VRC7 already read + it. A game that asked for a raster split got a counter about 114 times too + fast, and one that asked for cycle mode got a scanline counter. Found by + *San Guo Zhi* (mapper 252), whose text box drew with the map's tiles. - **VRC2 mirroring has two settings, not four.** A VRC2 ignores bit 1 of its mirroring register (nesdev "VRC2 and VRC4"); the one-screen settings are VRC4-only, and were decoded on the VRC2 too. From 3ead07a163dac63dfcc5446fa3391ba42ab7b6aa Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 03:18:05 -0400 Subject: [PATCH 065/104] docs: 208 mapper families, 181 accuracy-gated, in README, STATUS, mappers.md The current-state counts said 191 families = 51 Core / 109 Curated / 31 BestEffort (160 accuracy-gated), v3.1.0's numbers. v3.2.0 adds 17 families and promotes five, so the release's split is 208 = 51 / 130 / 27, with 181 accuracy-gated. Derived from the code, not from arithmetic on the old prose: `mapper_tier(id, 0)` over ids 0-4095, run by a throwaway probe crate against this tree, prints "Core 51 Curated 130 BestEffort 27 total 208". The submapper-dependent ids are still exactly 4, 91 and 176. The STATUS BestEffort cell is rewritten from the actual id list: - 17 ids above 255 (the NES 2.0 high-id boards, 290 among them). - 29, 39, 81, 104, 174, 179 and 238. - 50, 194 and 230. The old prose read "16 high-id + 8 with no matching cart", a split that no longer matches the ids even where the total did, so it now names them. Changed: the README badge, intro, feature table, feature list, tier paragraph and crate table; mappers.md "Current split" (and the out-of- scope note); STATUS's count chain (v3.2.0 to 208 is now "the current count", v2.9.6's 191 kept as history), the tier table and the accuracy-gated sentence. Historical statements ("191 at v2.9.6", the Tenth batch heading) are untouched. The release-version lines move at the cut, not here. The CLI's "101+ mapper families" is still true and left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- README.md | 14 +++++++------- docs/STATUS.md | 19 ++++++++++++------- docs/mappers.md | 9 +++++---- 3 files changed, 24 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 27e1b888..24763636 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

Build Status License: GPL-3.0-or-later Version Rust: 1.99
- AccuracyCoin nestest Mapper families Try in browser
+ AccuracyCoin nestest Mapper families Try in browser
Platform

@@ -21,7 +21,7 @@ sprite-zero hit, a mid-scanline scroll write or an MMC3 IRQ lands on the exact d without a per-game patch. It passes **AccuracyCoin 146/146** and matches the Nintendulator `nestest` log with **zero diff**. -Around that core sits a complete, modern platform: 191 mapper families, the +Around that core sits a complete, modern platform: 208 mapper families, the Famicom Disk System, Vs. System and PlayChoice-10 arcade boards, rollback netplay, RetroAchievements, a TAStudio piano-roll editor, a Mesen2-class debugger, HD packs, a shader stack, and native apps for desktop, the browser, @@ -73,7 +73,7 @@ audience: players, RetroArch, mobile and the Rust crates. | | | | --- | --- | | **Cycle-accurate** | CPU, PPU and APU on one master clock: AccuracyCoin 146/146, `nestest` 0-diff, blargg's CPU, APU and PAL suites | -| **191 mapper families** | NROM through MMC5, the whole VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company, the MMC3 and MMC1 multicarts, Waixing and Nanjing boards, homebrew flash boards with working saves, and a UNIF (`.unf`) loader. Each is classified Core, Curated or BestEffort by the evidence behind it | +| **208 mapper families** | NROM through MMC5, the whole VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company, the MMC3 and MMC1 multicarts, Waixing and Nanjing boards, homebrew flash boards with working saves, and a UNIF (`.unf`) loader. Each is classified Core, Curated or BestEffort by the evidence behind it | | **Famicom Disk System** | Real-BIOS boot, writable disks, side swapping, a timed disk-head model and 2C33 wavetable audio | | **Vs. / PlayChoice-10** | Arcade boards in true 2C03 / 2C04 / 2C05 RGB, per-game DIP presets, and Vs. DualSystem two-screen cabinets | | **Rollback netplay** | GGPO-style, up to four players over UDP or browser WebRTC, with room codes, TURN traversal and spectators; peers must match on ROM, emulation options and emulation epoch, so a session cannot silently diverge, and a mismatch is refused with its reason | @@ -166,7 +166,7 @@ in `crates/rustynes-frontend/web`. ### Cartridges -- **191 mapper families**, written from the NESdev wiki and pinned by test ROMs, +- **208 mapper families**, written from the NESdev wiki and pinned by test ROMs, unit tests and a commercial-ROM oracle. [`docs/mappers.md`](docs/mappers.md) lists every family, its tier and its evidence. - **Self-flashing homebrew boards** (GTROM, UNROM 512) emulate their SST39SF040 @@ -280,9 +280,9 @@ and the per-suite detail is in [`docs/STATUS.md`](docs/STATUS.md). When a document and a passing test ROM disagree, the ROM wins: that is this project's definition of cycle-accurate. -**Mapper tiers.** Of the 191 families, 160 are *accuracy-gated*: 51 Core and 109 +**Mapper tiers.** Of the 208 families, 181 are *accuracy-gated*: 51 Core and 130 Curated, each backed by a test ROM, a commercial dump, or a precise register -description with a synthetic fixture. The other 31 are *BestEffort*: their +description with a synthetic fixture. The other 27 are *BestEffort*: their documentation is thin, and a CI gate keeps them out of every accuracy claim. --- @@ -326,7 +326,7 @@ detailed in [`docs/architecture.md`](docs/architecture.md) and | Crate | Role | | --- | --- | | `rustynes-cpu`, `rustynes-ppu`, `rustynes-apu` | The 6502, 2C02 and 2A03 | -| `rustynes-mappers` | 191 mapper families, expansion audio, the UNIF loader | +| `rustynes-mappers` | 208 mapper families, expansion audio, the UNIF loader | | `rustynes-core` | The bus, scheduler, console and save states | | `rustynes-frontend` | The `winit` + `wgpu` + `cpal` + `egui` application (`rustynes`) | | `rustynes-netplay` | Rollback netcode over UDP and WebRTC | diff --git a/docs/STATUS.md b/docs/STATUS.md index d55a9de0..15ebcc9b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1980,8 +1980,10 @@ ROM dumps under `tests/roms/external/`, not committed): > **v1.7.0 "Forge" to 168** (Workstream G1's reusable-ASIC batch), and > **v1.8.9 "Backlog" beta.6 to 172** (the final NTDEC/TXC/BMC multicart batch — > m193/204/221/299 — plus a UNIF board-map breadth pass), **v2.3.4 to 174** -> (154 NAMCOT-3453 and 243 Sachen SA-020A), and **v2.9.6 "Roster" to 191**, -> the current count (the "Tenth batch" in `docs/mappers.md`). See +> (154 NAMCOT-3453 and 243 Sachen SA-020A), **v2.9.6 "Roster" to 191** (the +> "Tenth batch" in `docs/mappers.md`), and **v3.2.0 to 208**, the current count +> (its three v3.2.0 sections there: the Datach, the MMC3-derived boards and the +> TXC / Tengen / Taito / Waixing / multicart / FFE batch). See > `docs/mappers.md` §Mapper coverage matrix + > §Mapper accuracy tiering for the full current list. The "out of scope" notes > below were the early-engine scoping; they are retained as history and @@ -2001,24 +2003,27 @@ reusable-ASIC batch: FK23C, COOLBOY/MINDKIDS, Sachen 9602/3011, Waixing breadth pass wiring well-known board aliases to already-implemented families) → **174 families at v2.3.4** (154 NAMCOT-3453 and 243 Sachen SA-020A, both surfaced once the coverage harness applied the per-game database) → **191 -families at v2.9.6 "Roster"** (the current count: MMC3 boards 12/37/45/47/74/ +families at v2.9.6 "Roster"** (MMC3 boards 12/37/45/47/74/ 121/191/192/194/195/249, Cony 83, J.Y. 91, NES-EVENT 105, Bandai 153, Nanjing 163 and Action 52 228, all written from their NESdev pages; see -`docs/mappers.md` "Tenth batch"), +`docs/mappers.md` "Tenth batch") → **208 families at v3.2.0** (the current +count: the Bandai Datach 157, MMC3-derived 114/116/165/182/197/215, FFE 6/8/17, +Tengen 158, TXC 172/173, Taito 207, the 230 and 235 multicarts and Waixing 252), tiered for accuracy honesty (`crates/rustynes-mappers/src/tier.rs`): | Tier | Families | Accuracy-gated? | Evidence | |------|----------|-----------------|----------| | **Core** | 51 | Yes (AccuracyCoin + commercial oracle) | spec-implemented, oracle-locked | -| **Curated** (v1.2.0 + **v2.1.0 "Fathom" F3** + v2.9.6) | 109 | Yes | notable games + decode spec; register-decode unit tests **+ byte-identity boot-snapshot oracle** (`external_extended.rs`). The 14 added at v2.9.6 (13 new families and GTROM) have no redistributable dump; their evidence is a precise register table on the NESdev page, unit tests and a synthetic CC0 boot fixture (`roster_boards.rs`) | -| **BestEffort** | 31 | **No** | long-tail with **no cleanly-booting redistributable ROM dump** and thin documentation (16 NES 2.0 high-id boards + 8 with no matching cart + 50, whose only dump jams at boot + 154 and 243 from v2.3.4 + 47/121/191/194 from v2.9.6, whose pages give only Disch's notes or "probably" masks); register-decode + save-state unit tests only. Submappers 91.1 and 176.2 are also BestEffort, although their families are not (4.3, MC-ACC, was too until v2.9.7 promoted it to Curated on six real games) | +| **Curated** (v1.2.0 + **v2.1.0 "Fathom" F3** + v2.9.6 + v3.2.0) | 130 | Yes | notable games + decode spec; register-decode unit tests **+ byte-identity boot-snapshot oracle** (`external_extended.rs`). The 14 added at v2.9.6 (13 new families and GTROM) have no redistributable dump; their evidence is a precise register table on the NESdev page, unit tests and a synthetic CC0 boot fixture (`roster_boards.rs`). v3.2.0 adds 16 families booted from real dumps or written from a precise page, and promotes 47, 121, 154, 191 and 243 on local-dump evidence (D23, `docs/compatibility.md`) | +| **BestEffort** | 27 | **No** | long-tail with **no cleanly-booting redistributable ROM dump** and thin documentation (as `mapper_tier()` reports them at v3.2.0: 17 NES 2.0 high-id boards, 261-513 including 290; 29, 39, 81, 104, 174, 179 and 238, with no matching cart; 50, whose only dump jams at boot; 194 from v2.9.6, whose page gives only Disch's notes; and 230 from v3.2.0, whose page leaves a choice the boot does not settle); register-decode + save-state unit tests only. Submappers 91.1 and 176.2 are also BestEffort, although their families are not (4.3, MC-ACC, was too until v2.9.7 promoted it to Curated on six real games) | The **v2.1.0 "Fathom" F3** sweep promoted **86** families BestEffort → Curated (57 already-staged + 29 sourced from GoodNES v3.23b), taking accuracy-gated coverage from **60 → 146** of the then 172 families. With v2.3.4's two additions the count was **146 accuracy-gated of 174**. v2.9.6 made it **160 of 191**: 13 new families and GTROM (111) are Curated on a precise decode spec, and 4 new -families are BestEffort. +families are BestEffort. v3.2.0 makes it **181 of 208**: 16 new families are +Curated, 230 is BestEffort, and 47, 121, 154, 191 and 243 were promoted. A CI-checkable invariant forbids any `BestEffort` mapper from backing an oracle ROM (`rustynes-mappers::mapper_tier`; ADR 0011). The remaining tail (unlicensed diff --git a/docs/mappers.md b/docs/mappers.md index 5957e374..7a5540d6 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -1003,9 +1003,10 @@ boards with no redistributable fixture, register-decode unit-tested only) is oracle ROM — is enforced at the classifier level (`BestEffort` is structurally never accuracy-gated; the three tier id-sets are disjoint) and by the curated construction of the byte-oracle corpus. See `docs/adr/0011-mapper-tiering.md`. -Current split: **191 families** — 51 Core + 109 Curated (**160 accuracy-gated**) + -31 BestEffort (v2.3.4 added 154 and 243; v2.9.6 added 17 families and promoted -111, see above). Submapper-level exceptions: 91.1 and 176.2 are BestEffort, 4.3 +Current split: **208 families** — 51 Core + 130 Curated (**181 accuracy-gated**) + +27 BestEffort (v2.3.4 added 154 and 243; v2.9.6 added 17 families and promoted +111; v3.2.0 added 17 families, 230 of them BestEffort, and promoted 47, 121, +154, 191 and 243 on local-dump evidence, maintainer decision D23; see above). Submapper-level exceptions: 91.1 and 176.2 are BestEffort, 4.3 (MC-ACC) is Curated (BestEffort until v2.9.7), and 12.1 is unsupported. The **v2.1.0 "Fathom" F3** batch promoted **86** previously- BestEffort families to Curated: each has a **cleanly-booting** staged commercial-ROM dump (57 already in `tests/roms/external/` + 29 sourced from @@ -1192,6 +1193,6 @@ deleted. table in `docs/compatibility.md`). - **VRC7 FM audio.** YM2413-derived; only Lagrange Point uses it commercially. Banking + IRQ landed in Track C2 / Phase 2.4 (mapper 85; same `mapper-audio` feature flag as VRC6 / Sunsoft 5B / Namco 163 / MMC5). **The FM synthesizer landed** via a clean-room pure-Rust port of `emu2413 v1.5.9` (MIT) at `crates/rustynes-apu/src/opll.rs`; ADR 0006 (`docs/adr/0006-vrc7-audio-landed.md`) supersedes the ADR 0004 deferral. *Lagrange Point* plays with in-game audio (mixed via the `mapper-audio` slot). - **Pirate / multicart mappers.** *Answered by policy:* none were in the initial - scope; many are in now (191 families, `docs/STATUS.md`), each admitted under + scope; many are in now (208 families, `docs/STATUS.md`), each admitted under the long-tail policy in `docs/compatibility.md` (demand, a fixture or a specific NESdev page, and NES 2.0 detection). From 5eddcec513b378de29495237969314589e81a689 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 03:23:15 -0400 Subject: [PATCH 066/104] docs(roadmap): v3.2.0's slotted tickets, and seven follow-ups minted The v3.1 -> v4.0 ticket table slotted four tickets at v3.2.0. Each now says what this release did, from the code rather than from notes: - T-MAPPER-BREADTH-V3: the v3.2.0 part is done (17 oracle families, 191 -> 208; 16 MiSTer boards). v3.8.0's part remains. - T-KNOWN-BLANK: 41 remain. KNOWN_BLANK and FAMICOM_WIRED were counted in external_coverage.rs (41 and 10 entries). - T-CURATED-EVIDENCE: done, 47/121/154/191/243 under D23. - T-DUMP-CORPUS: left as is; v3.2.0 baselined 25 newly staged dumps but did not establish "every implemented family". New table of seven follow-ups, minted from the Round 2 ledger and the sibling integration, with proposed slots for the maintainer: - T-NETPLAY-DATAGRAM-AUTH (NET-R2-07). - T-ANDROID-AUDIO-NONBLOCKING (AND-R2-01). - T-ANDROID-GPU-FRAMESKIP (AND-01). - T-MAPPER71-DECODE (sibling 3.64). - T-ROUND2-UNTRIAGED (11 findings, CORE-R2-01 first). - T-MISTER-VRAM-M10K (RTL-R2-02). - T-MISTER-OFFDIE-CHRRAM (the pre-existing off-die read-back XFAILs). Two rows were checked against their sources before committing: - The mapper 71 row first said the oracle and the MiSTer core "agree". Sibling 3.64 says they DISAGREE: the oracle also decodes the bank select at $8000-$BFFF, the page and the core take $C000-$FFFF only. Corrected. - The off-die CHR-RAM row's two XFAILs were confirmed against regress.sh's own comment: one defect, both stems. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/ROADMAP.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 31ef0783..ae4aa3a6 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1455,9 +1455,9 @@ re-minted: `T-PS-dual-runahead` (ADR 0032; **DONE v3.1.0**), `T-MISTER-SAVESTATE | `T-PAL-EMPHASIS` | The PAL/Dendy emphasis red/green swap **DONE v3.1.0** | ACC-01 | v3.1.0 | | `T-COMPOSITE-ARTIFACTS` | Differential phase distortion and inter-pixel artifacts, an opt-in video option (D20) **DONE v3.1.0** (differential phase; the inter-pixel artifacts already existed in the signal-decode pass) | ACC-02 | v3.1.0 | | `T-MMC3-NEC-OVERRIDE` | The NEC rev B MMC3 as a per-game or config override (D20) **DONE v3.1.0** | ACC-13 | v3.1.0 | -| `T-MAPPER-BREADTH-V3` | The missing families with real titles, both cores (D26) | MAP-03, MAP-05, FB-1 | v3.2.0, v3.8.0 | -| `T-KNOWN-BLANK` | Triage the 52 KNOWN_BLANK dumps | ACC-08, ACC-09 | v3.2.0 | -| `T-CURATED-EVIDENCE` | BestEffort → Curated on local-dump evidence under D23's record | MAP-01 | v3.2.0 | +| `T-MAPPER-BREADTH-V3` | The missing families with real titles, both cores (D26). **v3.2.0 part DONE**: 17 oracle families (191 -> 208), 16 MiSTer boards; the rest is v3.8.0's | MAP-03, MAP-05, FB-1 | v3.2.0, v3.8.0 | +| `T-KNOWN-BLANK` | Triage the 52 KNOWN_BLANK dumps. **v3.2.0**: 41 remain; 10 Famicom-only carts boot on the Famicom model (`FAMICOM_WIRED`) | ACC-08, ACC-09 | v3.2.0 | +| `T-CURATED-EVIDENCE` | BestEffort → Curated on local-dump evidence under D23's record. **DONE v3.2.0**: 47, 121, 154, 191 and 243 (`docs/compatibility.md`) | MAP-01 | v3.2.0 | | `T-DUMP-CORPUS` | Every implemented family with a dump in `~/Dropbox/ROMs/` gets a recorded boot; nothing committed but snapshots (D21) | MAP-02 | v3.2.0 | | `T-PHI2-WRITE` | Reopen PPU register write placement against the v2.6.17 conditions (D20) | ACC-03 | v3.3.0 | | `T-SPRITE0-STALE` | The sprite-0 stale shifter and the internal/external bus split (D20) | ACC-04 | v3.3.0 | @@ -1474,6 +1474,20 @@ re-minted: `T-PS-dual-runahead` (ADR 0032; **DONE v3.1.0**), `T-MISTER-SAVESTATE | `T-MOBILE-DISTRIBUTION` | Android developer verification, iOS signing, and the free store listings (D18) | MOB-02, MOB-03 | v3.9.x | | `T-API-ENUMS` | The remaining public enums `#[non_exhaustive]`, the v4.0.0 API break (D3) | the backlog's MAJOR section | v3.9.x trial, v4.0.0 | +Minted 2026-10-14 by the v3.2.0 consolidation, from the Round 2 audit ledger +([`docs/audits/v3.2.0-round2-audit-disposition.md`](../docs/audits/v3.2.0-round2-audit-disposition.md)) +and the sibling's integration. Slots are proposals for the maintainer. + +| Ticket | What | Source | Slot | +| --- | --- | --- | --- | +| `T-NETPLAY-DATAGRAM-AUTH` | A per-session secret on every netplay datagram. The source-address filters the import added were reverted: they ended a session on a NAT rebinding and authenticated nothing | NET-R2-07 | v3.4.0, with `T-HOSTED-NETPLAY` | +| `T-ANDROID-AUDIO-NONBLOCKING` | Non-blocking `AudioTrack` writes with explicit pacing; v3.2.0 kept the blocking write, which is the pacing today | AND-R2-01 | v3.6.0, with `T-ANDROID-API37` | +| `T-ANDROID-GPU-FRAMESKIP` | Skip the CPU frame conversion on the GPU render path while keeping screenshots and clips working; needs a device to measure | AND-01 | v3.7.0 | +| `T-MAPPER71-DECODE` | Mapper 71's bank select: the oracle also decodes it at `$8000-$BFFF`, while the NESdev page, and the MiSTer core that follows it, take `$C000-$FFFF` only (sibling `docs/oracle-vs-documentation.md` 3.64). No gate exercises the difference, and which one a game needs is unsettled | sibling 3.64 | v3.3.0 | +| `T-ROUND2-UNTRIAGED` | The 11 Round 2 findings the import never applied, CORE-R2-01 first (it broke determinism when tried; a red test before any change) | Round 2 ledger | v3.3.0 | +| `T-MISTER-VRAM-M10K` | The 2 KiB nametable is 16,384 register bits; three read sites and a write need a port plan before it can move to block RAM without a latency change | RTL-R2-02 | a MiSTer F-phase release | +| `T-MISTER-OFFDIE-CHRRAM` | Off-die, a `$2007` CHR-RAM read soon after a write returns `$00` in simulation (pre-existing; `mapper13cprom206`, `mapper119tqrom230` are XFAILs) | sibling `docs/HARDWARE_TESTING.md` | v3.3.0 | + ## Open questions blocking planning None block the v3.1.0 start. The open maintainer decisions are tabled under From d02670a1129ec19b4f8f8e9405a577db68fe292a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 03:24:16 -0400 Subject: [PATCH 067/104] docs(agents): three v3.2.0 findings, with their evidence mister-cosim.md gains two bullets: - The rung-0 golden-provenance FAIL that every v3.2.0 branch ladder shared was attributed, by every agent and by the integrator, to "other branches' goldens". It was 26 blargg goldens whose ROMs the resolver could not find once a scratch clone was salvaged away. Fixed in sibling 16bfd9a. - The sprites agent exported goldens from an uncommitted exporter patch in a scratchpad oracle clone. It surfaced only through VERIFY=1 after the worktrees were removed, and landed as oracle 3f8a5552. tooling-traps.md gains one: `git -C ` resolves a relative path argument inside . It bit twice this session: a worktree created inside the sibling, and a hash-object comparison that compared nothing. AGENTS.md's index counts move to the files' real bullet counts: mister-cosim 33 -> 35, tooling-traps 19 -> 24. The tooling-traps count was already stale before this change (23 at HEAD). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- AGENTS.md | 4 ++-- docs/agents/mister-cosim.md | 2 ++ docs/agents/tooling-traps.md | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e4d74e11..eecec1d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,10 +216,10 @@ what a session needs before it knows what it is doing. | GitHub Actions, workflows, the release ceremony, lint coverage | [`docs/agents/ci-and-release.md`](docs/agents/ci-and-release.md) | 25 | | a PR review — the ceremony, where findings hide, which claims recur | [`docs/agents/review-bots.md`](docs/agents/review-bots.md) | 13 | | the libretro core, the buildbot, the upstream `.info`, RetroArch | [`docs/agents/libretro.md`](docs/agents/libretro.md) | 17 | -| the MiSTer sibling — the RTL, Quartus, the rungs, the bitstream | [`docs/agents/mister-cosim.md`](docs/agents/mister-cosim.md) | 33 | +| the MiSTer sibling — the RTL, Quartus, the rungs, the bitstream | [`docs/agents/mister-cosim.md`](docs/agents/mister-cosim.md) | 35 | | accuracy work — AccuracyCoin, blargg, sub-tests, goldens, the PPU | [`docs/agents/accuracy-oracle.md`](docs/agents/accuracy-oracle.md) | 23 | | reading a result — what it does and does not prove | [`docs/agents/measurement-discipline.md`](docs/agents/measurement-discipline.md) | 22 | -| the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 19 | +| the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 24 | | a dependency bump, or why one is blocked | [`docs/agents/dependencies.md`](docs/agents/dependencies.md) | 3 | | a performance claim, or a debugger panel that outlives its `Nes` | [`docs/agents/perf-and-panels.md`](docs/agents/perf-and-panels.md) | 7 | diff --git a/docs/agents/mister-cosim.md b/docs/agents/mister-cosim.md index b9584e6a..462ec023 100644 --- a/docs/agents/mister-cosim.md +++ b/docs/agents/mister-cosim.md @@ -46,3 +46,5 @@ - **`project_close -dont_export_assignments` DOES NOT KEEP THE `.qsf` UNTOUCHED (v2.8.4).** The first off-die build script set `VERILOG_MACRO "RUSTYNES_OFFDIE=1"` in memory from a Tcl `load_package flow` script and closed the project with that option. The compile succeeded (0 errors) and the tracked `RustyNES.qsf` came back with a blank line and the macro appended: `execute_flow -compile` writes the project's assignments itself, before the close runs. Only the script's own `git diff --quiet` check caught it. `scripts/build-offdie.sh` and `OFFDIE=1 scripts/seed-sweep.sh` now write the macro to disk and restore the file from a snapshot kept beside it, the way the seed sweep already handles `SEED`. Two traps found while fixing it: a `set -e` script that runs `cmd; rc=$?` never reaches its post-checks on the failing run, which is the run they exist for (`cmd || rc=$?`); and a trap that moves `output_files/` aside keyed on the directory EXISTING moved an empty one over a real `output_files_offdie/` on a sweep that compiled nothing. Key the move on "this run compiled", not on state that is always true. - **THE SDRAM MODEL READ ONE EDGE LATE, AND THE CONTROLLER WAS WRITTEN TO MATCH IT (v2.8.4). CORRECTS the note first committed here the same day, which called the model's third read-DQM stage "an offset to line up".** The datasheet (AS4C32M16SB Figure 20: READ at T5, CL2, `tAC` from T6, `tOH` past T7) puts a READ-at-M beat on the pins after edge M+CL-1, sampled at M+CL. `tb/sdram_model.sv` loaded it at pipe index CL (on the pins after M+CL) and `rtl/sdram.sv` loaded `wait_ctr <= CAS_LATENCY` (sampling at M+CL+1), so the two agreed and every gate passed, while silicon would have been sampled after `tOH` released the bus. The needed third DQM stage was the symptom, and it was documented as a quirk instead of investigated. No simulation could see it; the off-die compile's STA did, once R-4.6 constrained the SDRAM pins: the 16 DQ capture paths failed setup by 11.185 ns. Two things were wrong there: the constraint (the analyser latched 2.91 ns after the shifted pin clock, which no design meets; `set_multicycle_path -from SDRAM_DQ* -setup 2` states the real capture edge; **CORRECTED v2.9.0 (NR-02): the `-hold 1` that v2.8.4 put beside it moved the HOLD check one clock early, so v2.8.4 recorded +12.822 ns of DQ hold margin where the real figure is +1.181 ns. It is removed, `scripts/sta_dq.tcl` reports the path by name, and `check_timing.py --dq` fails a hold slack above one period**) and the RTL, which the code count showed sampling one edge later still. Fix: model loads index CL-1 and masks with `dqm_q2`, controller loads `CAS_LATENCY - 1`. Red first: the corrected model against the old controller read `0x0000` at every address at 11,640 ps. **When a model needs an odd adjustment to agree with the design, check the model against its source document before documenting the adjustment**: a model and the design written against it can share an error that no co-simulation will expose. - **`ORACLE=.oracle-pinned` SKIPS THE 16-GATE BLARGG INSTRUCTION BATTERY (v2.9.0).** Both v2.9.0 ladders were run with `ORACLE=/.oracle-pinned` (the clone `tb/fetch-goldens.sh` makes) so `golden-provenance` would read the pinned commit. That clone has no `tests/roms/nes-test-roms/instr_test-v5/rom_singles`, so `$INSTR_DIR` did not exist and `regress.sh` printed `SKIPPED: 1 ... [rung 1: blargg instruction battery]` above a green TOTAL -- 154 on-die, 156 off-die, against v2.8.4's full runs that skipped nothing. The skip was announced correctly (`skip_block` exists for exactly this); reading only the TOTAL line would still have missed it. Recovered by running the block's own command (`cpu-bus-gate ROM= ROM_PATH= PRG_RAM=1 CYCLES_OVERRIDE=`, plus `USE_SDRAM=1` off-die) in the same frozen checkout, after `git diff --stat -- tests/roms/nes-test-roms/instr_test-v5` in the main oracle came back empty: 16/16 each way. **Point `ORACLE` at a full oracle checkout, or pass `INSTR_DIR` explicitly, and read the `SKIPPED:` line before the TOTAL.** **CORRECTED v2.9.1: the clone was not missing anything the oracle tracks.** `tests/roms/nes-test-roms/` is a locally fetched corpus the oracle's `.gitignore` excludes; the same 16 ROMs are TRACKED at `tests/roms/blargg/instr_test_v5/` (byte-identical, `cmp`), which the golden recipes already used. `regress.sh` now reads them from there, so a pinned clone runs the battery and nothing needs pointing elsewhere. +- **A RUNG-0 FAIL EVERY LADDER SHARES IS NOT "SOMEONE ELSE'S GOLDENS" UNTIL IT IS READ (v3.2.0).** From 2026-10-09 every v3.2.0 branch ladder ended `1 failed`, always `golden-provenance`, and every agent and the integrator recorded it as "expected, other branches' new shared goldens; resolves on merge". It was not: the checker printed `--expect-unresolved 0 but 26 could not be located`, and the 26 were the 16 blargg `instr_test_v5` and 10 `apu_test` goldens, whose manifests name ROMs by absolute path inside a v3.1.0 SCRATCH clone of the oracle that the 2026-10-09 `/tmp` salvage moved away. The resolver's basename fallback searched the oracle's accuracycoin / AccuracyCoin / nestest / `tests/roms` directories but not the two `regress.sh` actually runs these batteries from (`INSTR_DIR`, `APU_TEST_DIR`), though its docstring says its fallbacks mirror `regress.sh`'s. Fixed in sibling `16bfd9a` (both directories searched, two self-test cases, a mutant that drops them fails exactly those two): 188 match, 0 UNRESOLVED. **A failure explained by a story nobody checked is a failure with no owner; open the line under the FAIL before attributing it.** +- **AN AGENT CAN EXPORT GOLDENS FROM AN ORACLE NOBODY COMMITTED (v3.2.0).** The sprites agent added `--sprite-limit-off` / `--fb-every-frame` to `nes_golden_export` in a SCRATCHPAD clone of the oracle, exported three goldens with it, and committed the goldens and the recipes but not the exporter; `golden-recipes.tsv` even recorded that "the pin predates" the flags. The worktrees were then removed on "branch merged + tree clean", and the patch survived only as a copy a `/tmp` salvage had made. `VERIFY=1 tb/fetch-goldens.sh` at the next pin is what exposed it (`usage` at `mapper4mmc3sprlim083`); after it landed (oracle `3f8a5552`) the same run reported 842 artifacts from 188 stems byte-for-byte. **Before deleting an agent's worktree, `VERIFY=1` the goldens against the oracle you will pin, and scan its transcript for writes into ANY repo copy, scratchpad clones included.** diff --git a/docs/agents/tooling-traps.md b/docs/agents/tooling-traps.md index e5b44492..0c067888 100644 --- a/docs/agents/tooling-traps.md +++ b/docs/agents/tooling-traps.md @@ -35,3 +35,4 @@ - **A WORKTREE-ISOLATED AGENT CANNOT RUN GIT HERE, AND ITS WORKTREE STARTS FROM `main` (v3.0.1).** Two forks were given `isolation: worktree` on 2026-10-07. Both worktrees were created from `main` (`9c23715b`), not from the release branch the session was on. The first agent could still run git and reset its own clean worktree to the right commit. The second could not run git at all: the RTK hook rewrites every `git` into `rtk git`, and the worktree-isolation guard refuses a command that "runs rtk with a git command among its operands" (plain `git`, `git -C`, `command git` and `rtk git` were all refused). It made no change, and the harness removed the unchanged worktree itself. For documents-only work, run the fork in the main tree instead, stage by explicit path, and make no commits from the session until it reports. Otherwise, check the agent's first `git log` before trusting anything it writes. - **`git show : > FILE` ON AN UNFETCHED TAG WRITES AN EMPTY FILE (v3.1.0).** The shell creates `FILE` before `git show` runs, so when the tag is not in the local repository (a release tag made on GitHub by Auto Release is not fetched by `git fetch origin main`), the command prints `fatal: invalid object name` and leaves a zero-byte file behind. Copied into the libretro-super fork, that was committed and pushed as a 38-line deletion of `rustynes_libretro.info` before the diff was read (caught before the PR, repaired by amend). Fetch the tag first (`git fetch origin tag vX.Y.Z`), and check `[ -s FILE ]` plus the `git diff --stat` before any commit built from a redirect. - **`workflow_dispatch` RUNS ONLY A WORKFLOW THAT IS ON THE DEFAULT BRANCH (v3.1.0).** `gh workflow run ladder.yml --ref release/v3.1.0` returned HTTP 404 "workflow ladder.yml not found on the default branch" while the file existed on the release branch. A new dispatch-only workflow therefore cannot be exercised before its PR merges; its first run is after the merge, and the PR should say so rather than imply it was tested. +- **`git -C ` RESOLVES A RELATIVE PATH ARGUMENT INSIDE `` (v3.2.0).** `git -C RustyNES_MiSTer worktree add RustyNES_MiSTer-wt-ladder-on `, run from the parent directory, created the worktree INSIDE the sibling (`RustyNES_MiSTer/RustyNES_MiSTer-wt-ladder-on`, an untracked directory in its tree), so the background ladders that `cd`'d to the intended sibling-of-the-repo path exited at once and read as "completed". The same session, `git -C hash-object ` hashed nothing and a 175-file salvage comparison reported every file as uncommitted. **Pass absolute paths to anything run under `git -C`, and treat an instant "completed" from a long job as a failure to start.** From 00c355a27d040da4ebc3a9aa11faa857b938e996 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 08:27:24 -0400 Subject: [PATCH 068/104] test(harness): list prg_may_unmap as board identity in the schema audit snapshot_schema_audit failed in the Step 9 gate run (in `cargo test --workspace` and both ROM-feature runs): SystemBus: 1 field(s) are neither serialized nor listed as deliberately excluded: ["prg_may_unmap"] The field was added in 5a38c02b (the PRG-window open-bus shortcut gated on a board flag). It caches Mapper::may_unmap_prg_rom(), whose contract (mapper.rs) is "read once when the bus takes the mapper, so it must not depend on runtime state". Every implementation was checked and is a constant: 228 and 235 return true, and 118, 119 and 252 delegate to an inner board that keeps the default false. The bus refreshes it at the same two points as mapper_caps: construction, and the fresh mapper instance in load_state. So it is board identity, recomputed on load, and belongs in `derived_or_config` beside mapper_caps. Serializing it would add a format change for a value the restore already rebuilds. The audit's message defaults to "serialize" (right three times of three, per its docs). This is the case its own rule admits: recomputable from configuration re-applied on load. Verified: the snapshot_schema_audit test binary passes 9 of 9. The earlier gate run stopped at this binary in all three test gates (no --no-fail-fast), so the full suite is re-run with --no-fail-fast after this commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-test-harness/tests/snapshot_schema_audit.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs index dc15c023..1982b1dd 100644 --- a/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs +++ b/crates/rustynes-test-harness/tests/snapshot_schema_audit.rs @@ -478,6 +478,12 @@ const CHIPS: &[Chip] = &[ "mapper_caps", "board identity: the mapper's static capability flags", ), + ( + "prg_may_unmap", + "board identity: Mapper::may_unmap_prg_rom, a per-type constant \ + (true only for 228 and 235), cached at construction and at the \ + mapper re-instantiation in load_state, like mapper_caps", + ), ( "rom_bytes", "board identity: the ROM image itself; a save state never carries it", From 5a7d8b2b35d5f2053095d7b97f4a39e443bb2e17 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 13:44:11 -0400 Subject: [PATCH 069/104] build(scripts): promote the local gate runner, containment check, A/B queue Three tools the release ceremony has used since v3.0.1 existed only in the gitignored salvaged/ and in session scratch. Promoted, each tested. scripts/release-automation/run_gates.sh runs every Rust gate ci.yml runs, one cargo job at a time, one PASS / FAIL / SKIP line each with a log per gate, never stopping early. The list was transcribed from ci.yml: each frontend feature set (`--all-targets` lints default features only), both wasm32 builds, the core trace features, the phi2 and noaudio variants, the thumbv7em no_std build, the cosim crate (outside the workspace), cargo deny, and the feature-gated test runs. `--only ` selects, `--no-roms` skips the ROM suites, and the commercial suite reports SKIP, never PASS, when tests/roms/external/ is empty. It replaces salvaged/tools/deps-gates.sh, which lacked the trace, phi2, cosim-fmt and feature-test gates. It does not run the fuzz job (nightly, its own workspace). Tested: --only '^fmt' (2 ran, 0 failed), an unmatched --only exits 2 rather than reporting an empty pass, and the commercial gate's SKIP path; shellcheck clean. A first draft said the v3.2.0 run of this list found a red snapshot_schema_audit that a workspace-only run had missed. That was wrong: 00c355a2 records the test failing in `cargo test --workspace` as well. The claim was removed before this commit; the script's case rests on the feature combinations a habitual local run never builds. scripts/release-automation/branch_contained.py answers "is everything this squash-merged branch or worktree added on main?" before deleting it. A squash merge makes a new commit, so `git branch --merged` never lists the branch and `-d` refuses it, while `-D` looks at nothing. It checks the branch's committed lines from the merge base, a worktree's uncommitted lines and its untracked files. The salvaged wtcheck.py it replaces checked only uncommitted lines, so it would have passed a branch with unmerged commits. Tested on a synthetic repository: after a squash merge and later work on main, 2 lines checked, 0 missing, exit 0; after one unmerged commit and a stray file, 1 MISSING line and 1 untracked file reported, exit 1. ruff check and format clean. scripts/perf/quiet_ab.sh: ab_check.sh's adoption rule needs a quiet host and the script does not wait for one. `run LABEL BASE` waits for a one-minute load under 1.5 with no BUSY lock, then measures holding MEASURING; `queue FILE` runs a list that way; `heavy CMD` waits out a measurement and holds BUSY so a ladder or suite cannot start under one. The v2.9.9 and v3.0.1 campaigns ran on its scratch predecessor; the v3.2.0 frame, mapper and PPU A/Bs ran on this one. Each log records the load at its start and end. Documented in scripts/release-automation/README-doc-tools.md and docs/agents/perf-and-panels.md (AGENTS.md's bullet count 7 -> 8). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- AGENTS.md | 2 +- docs/agents/perf-and-panels.md | 1 + scripts/perf/quiet_ab.sh | 109 ++++++++++++++++ .../release-automation/README-doc-tools.md | 23 ++++ .../release-automation/branch_contained.py | 120 +++++++++++++++++ scripts/release-automation/run_gates.sh | 122 ++++++++++++++++++ 6 files changed, 376 insertions(+), 1 deletion(-) create mode 100755 scripts/perf/quiet_ab.sh create mode 100755 scripts/release-automation/branch_contained.py create mode 100755 scripts/release-automation/run_gates.sh diff --git a/AGENTS.md b/AGENTS.md index eecec1d6..2e9acbb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,7 +221,7 @@ what a session needs before it knows what it is doing. | reading a result — what it does and does not prove | [`docs/agents/measurement-discipline.md`](docs/agents/measurement-discipline.md) | 22 | | the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 24 | | a dependency bump, or why one is blocked | [`docs/agents/dependencies.md`](docs/agents/dependencies.md) | 3 | -| a performance claim, or a debugger panel that outlives its `Nes` | [`docs/agents/perf-and-panels.md`](docs/agents/perf-and-panels.md) | 7 | +| a performance claim, or a debugger panel that outlives its `Nes` | [`docs/agents/perf-and-panels.md`](docs/agents/perf-and-panels.md) | 8 | **Read the file, not a summary of it.** Each bullet carries its own evidence — the command that was run, the number it returned, the mutation that caught it — diff --git a/docs/agents/perf-and-panels.md b/docs/agents/perf-and-panels.md index 1d017a36..8bd0e63f 100644 --- a/docs/agents/perf-and-panels.md +++ b/docs/agents/perf-and-panels.md @@ -20,3 +20,4 @@ - **Summing two percentiles is as invalid as differencing them.** `docs/performance.md` records the subtraction case (a published table whose `work p95` sat below its `work p50`). The addition case bit the v2.3.9 Latency Oracle design: an end-to-end figure needs `render_work + render_lock` (+ `render_wait`), and `PerfView` exposes those as three **separate** series, so the design was not implementable from existing data — found by trying to write it. The one valid case is adding a **constant**: internal lag is `frames * frame_ms`, so `lag + render_work.p95` genuinely is a p95. That rescues exactly one series, which is why `PerfPanelState::render_work` deliberately exposes only that one. A true wall-clock figure needs a new single per-redraw series on `RenderPerf`. - **A BENCH THAT DOES NOT NAME ITS CONFIGURATION MEASURES WHATEVER THE DEFAULT IS TODAY (2026-09-23).** v2.2.3 P1 flipped the PPU default to the fast dot path; the stock `full_frame` benches never called `set_fast_dotloop`, so for two months every stock/`*_fast` pair measured the same routine (3.936 vs 3.937 ms) and every "exact path" row in `docs/performance.md` was a fast-path number. Nothing failed: the benches ran, the gates passed, and the tables read plausibly. It surfaced only because a bench refactor forced `set_fast_dotloop(false)` and the A/B moved +12.7% on nestest -- the number that should have been there all along (4.458 exact vs 3.950 fast, -11.4%, matching v2.1.8 A1's -11.3%). **Select every configuration a bench is named for explicitly, both ways**, and when two benches of a pair agree to the microsecond, suspect that they measure the same thing before believing the optimisation is neutral. - **Retired instructions hide `memcpy`, so a copy-removing candidate needs cycles too (v2.8.1).** A `rep movsb` copy is one instruction, so `perf stat -e instructions:u` credits removing a pass over a 245 KB framebuffer with almost nothing and rewards a per-pixel loop that replaces it. The libretro one-pass blit retired 0.13% and 0.57% FEWER instructions than `memcpy` + swap, and was 1.0-2.6% SLOWER / mixed-sign in `cycles:u` taken as interleaved A/B pairs. Instructions are the right load-insensitive metric for compute, the wrong one for bandwidth; take both, and hash the output first so the candidates are known byte-identical (`docs/performance.md` §v2.8.1). +- **`ab_check.sh` asks for a quiet host and does not wait for one; `scripts/perf/quiet_ab.sh` does (v3.2.0).** `quiet_ab.sh run LABEL BASE` waits until the one-minute load is under 1.5 and no `BUSY` lock is held, then measures while holding `MEASURING`; `queue FILE` runs a list of A/Bs that way; `heavy CMD` waits out any measurement and holds `BUSY` for the duration, so a ladder or a test suite started during a campaign cannot land under one. The v2.9.9 and v3.0.1 campaigns ran on this queue from session scratch before it was promoted. Each log records the load at its start and end. A quiet start is necessary, not sufficient: the order-bias control and the second independent run still decide. diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh new file mode 100755 index 00000000..14d9d194 --- /dev/null +++ b/scripts/perf/quiet_ab.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# quiet_ab.sh — run scripts/perf/ab_check.sh only on a quiet host, and keep +# heavy jobs from starting underneath a measurement. +# +# `ab_check.sh`'s adoption rule needs two independent runs on a QUIET host, and +# it says so, but it does not wait for one. On a desktop that is also building, +# fuzzing or simulating, a run started by hand lands on a busy machine more +# often than not, and a busy-host result is noise that reads as a measurement. +# The v2.9.9 and v3.0.1 campaigns ran their A/Bs through a small queue that +# waits for the load to fall and holds two lock files; this is that queue, +# promoted from session scratch at v3.2.0. +# +# Subcommands: +# +# quiet_ab.sh run LABEL BASE [ab_check args...] +# Wait until no BUSY lock is held and the one-minute load average is below +# QUIET_LOAD (default 1.5), hold the MEASURING lock, and run +# `scripts/perf/ab_check.sh --base BASE [args]` in this checkout. The log, +# headed by the load at the start and the end, goes to LOG_DIR/LABEL.log. +# +# quiet_ab.sh queue FILE +# Pop lines `LABEL BASE [args...]` from FILE one at a time and `run` each, +# until FILE is empty or its first line is STOP. Lines may be appended +# while it runs; `#` lines and blank lines are skipped. Each finished line +# is appended to FILE.done. +# +# quiet_ab.sh heavy CMD [args...] +# Wait until no measurement holds MEASURING, then hold BUSY while CMD runs, +# so no queued A/B starts under it. Wrap ladders, sweeps and test suites +# in this while a queue is running. +# +# State (the two locks and the default logs) lives in PERF_STATE_DIR, default +# ${TMPDIR:-/tmp}/rustynes-perf. Both commands must see the same directory. +# +# What it does not do: make a run trustworthy. A quiet start is necessary, not +# sufficient; read ab_check.sh's order-bias control and its adoption rule +# before the candidate column, and take the second independent run anyway. +set -uo pipefail + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "quiet_ab.sh: not inside a git checkout" >&2; exit 2; } +state="${PERF_STATE_DIR:-${TMPDIR:-/tmp}/rustynes-perf}" +log_dir="${LOG_DIR:-$state/logs}" +quiet="${QUIET_LOAD:-1.5}" +busy="$state/BUSY" +measuring="$state/MEASURING" +mkdir -p "$state" "$log_dir" + +load1() { cut -d' ' -f1 /proc/loadavg; } + +run_one() { + local label="$1" base="$2"; shift 2 + local la + while :; do + if [ -e "$busy" ]; then sleep 30; continue; fi + la=$(load1) + if awk -v l="$la" -v q="$quiet" 'BEGIN { exit !(l < q) }'; then break; fi + sleep 60 + done + echo "$label" > "$measuring" + local rc + { + echo "LABEL $label BASE $base ARGS $* TREE $root @ $(git -C "$root" rev-parse --short HEAD)" + echo "START $(date -Is) LOAD1 $la ($(cat /proc/loadavg))" + ( cd "$root" && scripts/perf/ab_check.sh --base "$base" "$@" ) + rc=$? + echo "END $(date -Is) rc=$rc ($(cat /proc/loadavg))" + } > "$log_dir/$label.log" 2>&1 + rm -f "$measuring" + echo "$label done (start load $la), log $log_dir/$label.log" + grep -E "^LABEL|^START|^END|change:|^==>" "$log_dir/$label.log" +} + +case "${1:-}" in + run) + shift + [ $# -ge 2 ] || { echo "usage: quiet_ab.sh run LABEL BASE [ab_check args...]" >&2; exit 2; } + trap 'rm -f "$measuring"' EXIT + run_one "$@" ;; + queue) + q="${2:-}" + [ -f "$q" ] || { echo "usage: quiet_ab.sh queue FILE (FILE must exist)" >&2; exit 2; } + trap 'rm -f "$measuring"' EXIT + while :; do + line=$(grep -v '^[[:space:]]*$' "$q" | grep -v '^#' | head -1) + if [ -z "$line" ] || [[ "$line" == STOP* ]]; then echo "queue empty"; break; fi + # Remove exactly that first occurrence, so lines appended meanwhile stay. + python3 - "$q" "$line" <<'PY' +import sys +path, line = sys.argv[1], sys.argv[2] +lines = open(path).read().splitlines() +lines.remove(line) +open(path, "w").write("\n".join(lines) + ("\n" if lines else "")) +PY + # `line` holds LABEL BASE and flags, which must word-split. + # shellcheck disable=SC2086 + run_one $line + echo "$line" >> "$q.done" + done ;; + heavy) + shift + [ $# -ge 1 ] || { echo "usage: quiet_ab.sh heavy CMD [args...]" >&2; exit 2; } + while [ -e "$measuring" ]; do sleep 20; done + touch "$busy" + trap 'rm -f "$busy"' EXIT + "$@" ;; + *) + sed -n '/^set -uo/q;2,$p' "$0" + exit 2 ;; +esac diff --git a/scripts/release-automation/README-doc-tools.md b/scripts/release-automation/README-doc-tools.md index b53806fd..7344689e 100644 --- a/scripts/release-automation/README-doc-tools.md +++ b/scripts/release-automation/README-doc-tools.md @@ -70,3 +70,26 @@ corrected in the PR #349 review rather than left standing. `reflow.py` — stdlib-only and fully testable — has real coverage in `test_reflow.py`. + +## Release-cut helpers promoted from `salvaged/` (2026-10-14) + +Two tools that had lived only in the gitignored `salvaged/tools/` and in session +scratch, used at every release cut since v3.0.1 and promoted at v3.2.0 so the +next cut does not have to rebuild them. + +| script | needs | what it does | +| --- | --- | --- | +| `run_gates.sh` | the repo's toolchain, `cargo-deny`, the wasm32 and thumbv7em targets | Runs every Rust gate `.github/workflows/ci.yml` runs, one cargo job at a time, and prints one PASS / FAIL / SKIP line per gate with a log per gate. `--only ` selects gates, `--no-roms` skips the ROM suites. The commercial suite is SKIP, never PASS, when no dumps are staged. | +| `branch_contained.py` | stdlib only | Before deleting a squash-merged branch or a worktree: checks that every line the branch added (committed from the merge base, plus a worktree's uncommitted lines and untracked files) is on `main`. Exit 1 lists what is missing. | + +`run_gates.sh` is a transcription, so it drifts: when `ci.yml` gains a gate, add +it there in the same change. Its value is the feature combinations: most of its +gates name a feature set, target or crate that an everyday +`cargo clippy --workspace` or `cargo test --workspace` never builds. + +`branch_contained.py` replaces a `wtcheck.py` that checked only a worktree's +uncommitted lines, which would have passed a branch with unmerged commits. Its +line test is a heuristic: a line moved to another file reads as missing (safe), +so read every MISSING line before deleting. Tested against a synthetic squash +merge: 0 missing after the merge, and 1 missing line plus 1 untracked file once +an unmerged commit and a stray file were added. diff --git a/scripts/release-automation/branch_contained.py b/scripts/release-automation/branch_contained.py new file mode 100755 index 00000000..a9a97883 --- /dev/null +++ b/scripts/release-automation/branch_contained.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Is everything a branch or worktree added already on `main`? + +Run before deleting a branch or removing a worktree after a squash merge. + +Why a line check and not git's own: a squash merge makes a NEW commit, so +`git branch --merged main` never lists the branch and `git branch -d` refuses +it, while `-D` deletes it without looking. Comparing trees fails too, because +`main` has moved on since the merge. What is actually wanted is "every line this +branch added is present in that file on `main`", which survives both. + +It checks three sources of added lines: + +* the branch's committed lines: `git diff
...` (from the merge + base, so lines `main` added meanwhile do not count against the branch); +* in a worktree, its uncommitted lines: `git diff HEAD`; +* in a worktree, its untracked files: each must exist on `main` at the same path. + +A line counts as present when its stripped text occurs anywhere in the same +path on `main`. Lines shorter than 8 characters (braces, `end`, blank-ish +lines) are skipped as uninformative. This is a containment heuristic, not a +proof: a line moved to a different file reads as missing (a false alarm, safe), +and a line that also occurs elsewhere in the file reads as present. Read every +MISSING line before deleting anything. + +The v3.1.0 and v3.2.0 cleanups used an earlier form of this, which checked +only uncommitted lines. It was enough for those worktrees, but it would have +passed a branch with unmerged commits. + +Usage: + branch_contained.py [--branch NAME] [--main main] + +Exit status: 0 when nothing is missing, 1 when something is, 2 on a usage or +git error. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from collections import defaultdict + +MIN_LEN = 8 +SHOW_MISSING = 12 + + +def git(repo: str, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True, check=check) + + +def added_lines(diff: str) -> dict[str, list[str]]: + """Map each `+++ b/` in a unified diff to its added, stripped lines.""" + out: dict[str, list[str]] = defaultdict(list) + cur = None + for line in diff.splitlines(): + if line.startswith("+++ "): + cur = line[6:] if line.startswith("+++ b/") else None + elif line.startswith("+") and cur is not None: + text = line[1:].strip() + if len(text) >= MIN_LEN: + out[cur].append(text) + return out + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("path", help="a worktree, or the repository holding the branch") + ap.add_argument("--branch", help="branch to check (default: the one checked out at PATH)") + ap.add_argument("--main", default="main", help="the branch it was merged into (default: main)") + a = ap.parse_args() + + try: + branch = a.branch or git(a.path, "branch", "--show-current").stdout.strip() + if not branch: + print("detached HEAD: pass --branch", file=sys.stderr) + return 2 + committed = git(a.path, "diff", f"{a.main}...{branch}").stdout + checked_out = git(a.path, "branch", "--show-current").stdout.strip() == branch + uncommitted = git(a.path, "diff", "HEAD").stdout if checked_out else "" + untracked = ( + git(a.path, "ls-files", "--others", "--exclude-standard").stdout.split("\n") + if checked_out + else [] + ) + except subprocess.CalledProcessError as e: + print(f"git failed: {' '.join(e.cmd)}\n{e.stderr.strip()}", file=sys.stderr) + return 2 + + lines = added_lines(committed) + for path, extra in added_lines(uncommitted).items(): + lines[path].extend(extra) + + total = missing = 0 + for path, texts in sorted(lines.items()): + on_main = git(a.path, "show", f"{a.main}:{path}", check=False).stdout + for text in texts: + total += 1 + if text not in on_main: + missing += 1 + if missing <= SHOW_MISSING: + print(f" MISSING {path}: {text[:110]}") + if missing > SHOW_MISSING: + print(f" ... and {missing - SHOW_MISSING} more") + + absent = 0 + for path in filter(None, untracked): + if git(a.path, "cat-file", "-e", f"{a.main}:{path}", check=False).returncode != 0: + absent += 1 + print(f" UNTRACKED, NOT ON {a.main}: {path}") + + print( + f"{branch}: {total} added lines checked, {missing} not found on {a.main}; " + f"{len([u for u in untracked if u])} untracked files, {absent} not on {a.main}" + ) + return 0 if missing == 0 and absent == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release-automation/run_gates.sh b/scripts/release-automation/run_gates.sh new file mode 100755 index 00000000..3570f961 --- /dev/null +++ b/scripts/release-automation/run_gates.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# run_gates.sh — run every Rust gate CI runs, locally, one cargo job at a time, +# and print one PASS/FAIL line per gate. +# +# Why this exists: CI's gate set is spread across `.github/workflows/ci.yml`, +# and the invocations that catch real defects are the ones a habitual local +# `cargo clippy --workspace` never reaches: each feature set of the frontend +# (`--all-targets` lints DEFAULT features only, so a cfg-gated module is unlinted +# until a command names its feature), both wasm32 builds (unreachable from a +# native target), the trace features of `rustynes-core`, the `no_std` thumbv7em +# build, and the cosim crate, which `--workspace` does not include. The list +# below was transcribed from ci.yml for the v3.2.0 cut. When ci.yml gains a +# gate, add it here in the same change. +# +# It never stops early: a red gate is reported and the next one still runs, so +# one invocation gives the whole picture. Each gate's full output goes to its +# own log; the summary names the log of every failure. +# +# Usage: +# scripts/release-automation/run_gates.sh # everything +# scripts/release-automation/run_gates.sh --only clippy # gates whose name matches a regex +# scripts/release-automation/run_gates.sh --no-roms # skip the ROM suites (the slow part) +# GATES_LOG_DIR=/path scripts/release-automation/run_gates.sh +# +# The `test-roms-commercial` gate runs only when local dumps are staged under +# `tests/roms/external/` (gitignored; see AGENTS.md "Never commit commercial +# Nintendo ROMs"). Without them it is reported as SKIP, never as PASS. +# +# Exit status: 0 when every gate that ran passed, 1 otherwise. +set -u + +root=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "run_gates.sh: not inside a git checkout" >&2; exit 2; } +cd "$root" || exit 2 + +only="" +roms=1 +while [ $# -gt 0 ]; do + case "$1" in + --only) only="${2:-}"; shift 2 ;; + --no-roms) roms=0; shift ;; + -h|--help) sed -n '2,/^# Exit status/p' "$0"; exit 0 ;; + *) echo "run_gates.sh: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +log_dir="${GATES_LOG_DIR:-${TMPDIR:-/tmp}/rustynes-gates}" +mkdir -p "$log_dir" +echo "HEAD $(git rev-parse --short HEAD) $(git branch --show-current), logs in $log_dir" + +failed=0 +ran=0 +gate() { + local name="$1"; shift + if [ -n "$only" ] && ! [[ "$name" =~ $only ]]; then return; fi + local t0=$SECONDS + "$@" > "$log_dir/$name.log" 2>&1 + local rc=$? + ran=$((ran + 1)) + if [ $rc -eq 0 ]; then + echo "GATE $name: PASS ($((SECONDS - t0))s)" + else + failed=$((failed + 1)) + echo "GATE $name: FAIL rc=$rc ($((SECONDS - t0))s) -- $log_dir/$name.log" + fi +} +skip() { + if [ -n "$only" ] && ! [[ "$1" =~ $only ]]; then return; fi + echo "GATE $1: SKIP ($2)" +} + +cosim=crates/rustynes-cosim/Cargo.toml + +gate fmt cargo fmt --all --check +gate fmt-cosim cargo fmt --manifest-path "$cosim" --check +gate clippy-workspace cargo clippy --workspace --all-targets -- -D warnings +gate clippy-cosim cargo clippy --manifest-path "$cosim" --all-targets --all-features -- -D warnings +for f in cpu-boot-trace irq-timing-trace ppu-state-trace ppu-fetch-trace; do + gate "clippy-core-$f" cargo clippy -p rustynes-core --all-targets --features "$f" -- -D warnings +done +gate clippy-harness-traces cargo clippy -p rustynes-test-harness --all-targets --features cpu-boot-trace,irq-timing-trace,ppu-state-trace -- -D warnings +gate clippy-harness-phi2 cargo clippy -p rustynes-test-harness --all-targets --features test-roms,phi2-write-sweep -- -D warnings +gate clippy-ppu-phi2-pst cargo clippy -p rustynes-ppu --all-targets --features ppu-state-trace,phi2-write-sweep -- -D warnings +gate clippy-frontend-scripting cargo clippy -p rustynes-frontend --all-targets --features scripting -- -D warnings +gate clippy-frontend-scripting-hdpack cargo clippy -p rustynes-frontend --all-targets --features scripting,hd-pack -- -D warnings +gate clippy-frontend-ra cargo clippy -p rustynes-frontend --all-targets --features retroachievements -- -D warnings +gate clippy-frontend-full cargo clippy -p rustynes-frontend --all-targets --features full -- -D warnings +gate clippy-mappers-noaudio cargo clippy -p rustynes-mappers --no-default-features --all-targets -- -D warnings +gate clippy-harness-commercial cargo clippy -p rustynes-test-harness --all-targets --features commercial-roms,debug-hooks -- -D warnings +gate clippy-harness-roms cargo clippy -p rustynes-test-harness --all-targets --features test-roms,commercial-roms -- -D warnings +gate clippy-wasm cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --lib --bins -- -D warnings +gate clippy-wasm-canvas cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --no-default-features --features wasm-canvas --lib --bins -- -D warnings +gate clippy-wasm-browser-cheevos cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --features browser-cheevos --lib --bins -- -D warnings +gate rustdoc env RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps +gate rustdoc-cosim env RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --manifest-path "$cosim" +gate no-std cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features +gate deny cargo deny check +gate test-workspace cargo test --workspace +gate test-cosim cargo test --manifest-path "$cosim" +gate test-mappers-noaudio cargo test -p rustynes-mappers --no-default-features +gate test-frontend-full cargo test -p rustynes-frontend --lib --features full +gate test-core-features cargo test -p rustynes-core --features debug-hooks,hd-pack +gate test-ppu-features cargo test -p rustynes-ppu --features debug-hooks,hd-pack +gate test-apu-features cargo test -p rustynes-apu --features debug-hooks +gate test-script-features cargo test -p rustynes-script --features script-ipc,script-sqlite +if [ $roms -eq 1 ]; then + gate test-roms cargo test --workspace --features test-roms + if [ -d tests/roms/external ] && [ -n "$(ls -A tests/roms/external 2>/dev/null)" ]; then + gate test-roms-commercial cargo test --workspace --features test-roms,commercial-roms + else + skip test-roms-commercial "no dumps staged under tests/roms/external/" + fi +else + skip test-roms "--no-roms" + skip test-roms-commercial "--no-roms" +fi + +if [ $ran -eq 0 ]; then + echo "run_gates.sh: no gate matched '--only $only'" >&2 + exit 2 +fi +echo "$ran gates ran, $failed failed" +[ $failed -eq 0 ] From d98a678e6b0400a1eb5a532d2b14dc70e333cc0e Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 13:44:44 -0400 Subject: [PATCH 070/104] test(fuzz): fuzz the five foreign-movie importers The importers for FCEUX .fm2 and .fcm, BizHawk .bk2, Famtasia .fmv and VirtuaNES .vmv read files users download, and no fuzz target reached them: `movie` fuzzes only RustyNES's own .rnm decoder. A v2.9.9 probe had pushed 200,000 mutated inputs through them from session scratch, which is where it stayed. movie_import selects the importer by the first input byte (% 5), so one corpus reaches all five; .bk2's header and input log are split at the first NUL. A movie that imports is serialized to .rnm, as the frontend does next. import_mc2 is left out: it rejects every input without reading it (.mc2 is a PC Engine format). The ROM hash is a constant; it steers no parse. Measured: CI's configuration (20,000 inputs, seed 1) and 2,000,000 more (seed 7) with no crash. The first local run reported a "crash" on the empty input: LeakSanitizer cannot attach in this sandbox, the case fuzz/README.md documents; with ASAN_OPTIONS=detect_leaks=0 it is clean. nightly clippy -D warnings and rustfmt clean. CI's fuzz job now expects at least nine targets and its name says nine; `main` has no branch protection, so no required-check name was keyed to the old one. fuzz/README.md and docs/testing-strategy.md list it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/workflows/ci.yml | 7 ++-- docs/testing-strategy.md | 6 ++- fuzz/Cargo.toml | 7 ++++ fuzz/README.md | 1 + fuzz/fuzz_targets/movie_import.rs | 66 +++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 fuzz/fuzz_targets/movie_import.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8689e02d..492fc0d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1042,7 +1042,8 @@ jobs: # v2.9.4 — the eight fuzz targets under fuzz/ were built by nobody: no # workflow mentioned them, so a target could stop compiling and nothing would - # say so. This builds all eight and runs each for a FIXED number of inputs from + # say so. This builds them all (nine since v3.2.0's `movie_import`, the five + # foreign-movie importers) and runs each for a FIXED number of inputs from # a FIXED seed, so a run is reproducible and a failure is a real crash, not a # time-box artifact. It starts from an empty corpus (fuzz/corpus is local). # Measured on the maintainer's machine with the local corpus: ~70 s for all @@ -1051,7 +1052,7 @@ jobs: # libFuzzer needs nightly; the fuzz crate is its own workspace (fuzz/Cargo.toml), # so the project pin in rust-toolchain.toml does not apply to it. fuzz: - name: fuzz (8 targets, fixed seed) + name: fuzz (9 targets, fixed seed) needs: [changes, setup] if: ${{ needs.changes.outputs.code == 'true' && needs.setup.outputs.heavy == 'true' }} runs-on: ubuntu-26.04 @@ -1082,7 +1083,7 @@ jobs: cargo +nightly fuzz build -O --target x86_64-unknown-linux-gnu targets=$(cargo +nightly fuzz list) n=$(printf '%s\n' "$targets" | grep -c .) - [ "$n" -ge 8 ] || { echo "::error::expected at least 8 fuzz targets, found $n"; exit 1; } + [ "$n" -ge 9 ] || { echo "::error::expected at least 9 fuzz targets, found $n"; exit 1; } # Run EVERY target even after one crashes, so a single run reports # all of them rather than the first (agy on #574). failed="" diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index ba4316ea..f1c0b6ed 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -192,8 +192,12 @@ nightly-only because libFuzzer needs it): network input). - **`save_state`** / **`movie`**: the `.rns` and `.rnm` deserialisers (untrusted file input). +- **`movie_import`** (v3.2.0): the five foreign-movie importers (`.fm2`, `.bk2`, + `.fcm`, `.fmv`, `.vmv`), selected by the first input byte. Promoted from a + v2.9.9 probe that pushed 200,000 mutated inputs through them; clean over 2,000,000 + inputs when added. -**In CI since v2.9.4** (the `fuzz` job in `ci.yml`): all eight are built and each +**In CI since v2.9.4** (the `fuzz` job in `ci.yml`): all nine are built and each runs 20,000 inputs from seed 1, so a failure is reproducible, and a crash input is uploaded as an artifact. Before v2.9.4 no workflow built them. Locally: `cd fuzz && cargo +nightly fuzz run -O -- -runs=20000 -seed=1`. Under a diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index c4796cf9..ed1265a2 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -85,3 +85,10 @@ path = "fuzz_targets/movie.rs" test = false doc = false bench = false + +[[bin]] +name = "movie_import" +path = "fuzz_targets/movie_import.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md index 0c2ff6c1..82d4d6ed 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -27,6 +27,7 @@ threads through `rustc`. Install with `rustup toolchain install nightly`. | `netplay_message` | `NetMessage::from_bytes` + `SignalMessage::parse` | **untrusted network input** — binary UDP + JSON signaling/lobby parsers | | `save_state` | `parse_header` + `Nes::extract_thumbnail` + `restore_quiet` | untrusted `.rns` file: header + section length prefixes | | `movie` | `Movie::deserialize` | untrusted `.rnm` file: header, embedded `.rns` start-point, per-frame stream | +| `movie_import` | `import_fm2` / `import_bk2` / `import_fcm` / `import_fmv` / `import_vmv` | untrusted foreign movies; the first byte selects the importer (v3.2.0) | The v2.2.0 targets cover the remaining untrusted-input boundaries: the two CPU-facing chip register files, the netplay network parsers (highest value — diff --git a/fuzz/fuzz_targets/movie_import.rs b/fuzz/fuzz_targets/movie_import.rs new file mode 100644 index 00000000..caef6dc4 --- /dev/null +++ b/fuzz/fuzz_targets/movie_import.rs @@ -0,0 +1,66 @@ +//! Fuzz target for the foreign-movie importers — untrusted **file input**. +//! +//! The frontend imports TAS movies written by other emulators: FCEUX's `.fm2` +//! (text) and `.fcm` (binary), BizHawk's `.bk2` (a header and an input log, +//! both text), Famtasia's `.fmv` and VirtuaNES's `.vmv` (binary). Each is an +//! arbitrary file a user downloads, so each importer must reject a malformed, +//! truncated or hostile file with its typed error, never panic, index out of +//! bounds or allocate without bound. The `movie` target fuzzes only RustyNES's +//! own `.rnm` decoder (`Movie::deserialize`); these five parsers had no target +//! until v3.2.0, though a v2.9.9 probe pushed 200,000 mutated inputs through +//! them. That probe is what this target replaces. +//! +//! The first input byte selects the importer, so one corpus reaches all five: +//! +//! | `data[0] % 5` | importer | rest of the input | +//! |---|---|---| +//! | 0 | `import_fm2` | UTF-8 text (lossy) | +//! | 1 | `import_bk2` | header and input log, split at the first NUL byte | +//! | 2 | `import_fcm` | bytes | +//! | 3 | `import_fmv` | bytes | +//! | 4 | `import_vmv` | bytes | +//! +//! `import_mc2` is left out: it rejects every input without reading it (`.mc2` +//! is a PC Engine format). +//! +//! Any movie that imports is serialized to `.rnm`, as the frontend does next, +//! so the encoder must accept whatever an importer produced. +//! +//! Run with: +//! cargo +nightly fuzz run movie_import +//! +//! Per `docs/testing-strategy.md` §Layer 5. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use rustynes_core::{ + bk2_interop::import_bk2, import_fcm, import_fmv, import_vmv, movie_interop::import_fm2, +}; + +/// The importers take the ROM's identity to stamp into the movie; its value +/// does not steer any parse, so a constant keeps the search on the file bytes. +const SHA: [u8; 32] = [0; 32]; + +fuzz_target!(|data: &[u8]| { + let Some((&sel, rest)) = data.split_first() else { + return; + }; + let movie = match sel % 5 { + 0 => import_fm2(&String::from_utf8_lossy(rest), SHA) + .ok() + .map(|(m, _)| m), + 1 => { + let split = rest.iter().position(|&b| b == 0).unwrap_or(rest.len()); + let header = String::from_utf8_lossy(&rest[..split]); + let log = String::from_utf8_lossy(rest.get(split + 1..).unwrap_or(&[])); + import_bk2(&header, &log, SHA).ok().map(|(m, _)| m) + } + 2 => import_fcm(rest, SHA).ok().map(|(m, _)| m), + 3 => import_fmv(rest, SHA).ok().map(|(m, _)| m), + _ => import_vmv(rest, SHA).ok().map(|(m, _)| m), + }; + if let Some(movie) = movie { + let _ = movie.serialize(); + } +}); From bf3582f5ded0454da93897a3cfcedbe0056ec72a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 13:45:01 -0400 Subject: [PATCH 071/104] docs(plans): keep the three surveys the v3.1-to-v4.0 plan came from v3.1-to-v4.0-line-plan.md says it was drafted from "three read-only surveys of both repositories and the outside ecosystem". None of the three was in either repository; they survived only because session scratch was rescued into the gitignored salvaged/. The plan carries their conclusions but not their per-item evidence (status, size, blocker, what each breaks, and the source of each ecosystem claim). Added verbatim under to-dos/plans/research/ (markdownlint-exempt, kept as authored, like the dives already there): - v3.1-line-survey-oracle-backlog.md - v3.1-line-survey-mister-backlog.md - v3.1-line-survey-ecosystem.md Screened before adding: no personal paths, no private-repository links; the only addresses are the MiSTer project's public submission addresses. The research README and the line plan link them; the README says the statuses are as of 2026-10-06. Measured, not taken from the triage agent's report: the oracle backlog's tables carry 86 item ids, and 25 of them appeared nowhere in docs/, to-dos/, ROADMAP.md, VERSION-PLAN.md or CHANGELOG.md (the agent had said 25 of 94; the 94 counted ids in prose too). A first count gave 23 because the new README itself names PROV-01 and PROV-02; excluding it gives 25. T-SURVEY-UNTICKETED (ROADMAP, release-cut commit) triages them. docs/SALVAGE_MANIFEST.md records what left salvaged/ for both repositories on 2026-10-14 and why the rest stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- docs/SALVAGE_MANIFEST.md | 52 ++ to-dos/plans/research/README.md | 19 + .../research/v3.1-line-survey-ecosystem.md | 530 ++++++++++++++++++ .../v3.1-line-survey-mister-backlog.md | 369 ++++++++++++ .../v3.1-line-survey-oracle-backlog.md | 357 ++++++++++++ to-dos/plans/v3.1-to-v4.0-line-plan.md | 6 +- 6 files changed, 1332 insertions(+), 1 deletion(-) create mode 100644 to-dos/plans/research/v3.1-line-survey-ecosystem.md create mode 100644 to-dos/plans/research/v3.1-line-survey-mister-backlog.md create mode 100644 to-dos/plans/research/v3.1-line-survey-oracle-backlog.md diff --git a/docs/SALVAGE_MANIFEST.md b/docs/SALVAGE_MANIFEST.md index 93fc45c9..cb5aac75 100644 --- a/docs/SALVAGE_MANIFEST.md +++ b/docs/SALVAGE_MANIFEST.md @@ -233,3 +233,55 @@ trees listed below. Moved with a SHA-256 check before each move: 883 files, | Extracted commercial ROMs and frames rendered from them | small | `v320/g2`, `v320/roms290`, PNG dumps: derived from commercial dumps, never for the tree. | | 60 loose `/tmp` patches, diffs and parse scripts | ~1.5 MB | Review-bot artifacts of RustyNES PRs (Oct 5-8): regenerable from git, the `.md` drafts already posted. Left by the maintainer's choice. | | Foreign agent scratch | 15,425 matches in 39 groups | Other projects' (VeridianOS, MARM-Stack, KDE dry runs and more). Left by the maintainer's choice. | + +## 2026-10-14 — v3.2.0 release cut, session `f63c2db0` (second pass) + +Scope chosen by the maintainer: **hand-written scripts and docs only** from this +session's agent scratchpad (`/tmp/claude-1000//f63c2db0-.../scratchpad`), +and none of the `/tmp` test leftovers. The tool's own scope could not express that +(its `--exclude` does not filter directory candidates, and inside the scratch tree it +ranks logs as strong), so the list was built by hand from file type, as on +2026-10-09. Copied with a byte compare before each original was removed: 93 files, +about 864 KB, 0 identical-skipped, 0 conflicts, 0 errors. + +### Claimed (93 files) + +| Source | Destination | Notes | +|---|---|---| +| `scratchpad/*` hand-written files (70) | `salvaged/tmp-scratch/f63c2db0/` | The gate runner `gates.sh`, `double-compile.sh`, `roadmap_chain.py`, `sheet.py`, `scan_scratch_clones.py`, `scan_transcripts.py`; 50 commit-message drafts (`msg-*.txt`); the v3.2.0 release-notes draft (its committed form is `.github/release-notes/v3.2.0.md`); pre-edit source snapshots from the agy-branch audit (`m019.{base,import,reverted}.rs`, `mesh_net.{base,import}.rs`, `piccolo.with-cap-and-test.rs`, `cgp.{head,fixed}.py`, `regress.head.sh`) and the AI-made palette split (`palette.split.sv`, `palsplit.patch`); id lists (`r2ids.txt`, `bless25-ids.txt`, `agy-files.txt`, `import-files.txt`, `manifest-edits.json`). | +| `scratchpad/pr598/` (19) | same relative path | Drafted replies and the close comment for PR #598. The PR's document carried an email address and a private Drive id, so this stays in the gitignored tree. | +| `scratchpad/{shotprobe,tiercount}/` (4) | same relative path | Two probe crates' `Cargo.toml` and `src/main.rs`. | + +### Found and deliberately NOT claimed + +| Group | Why not | +|---|---| +| Every scratchpad log (gate logs, ladders, seed sweeps, A/B runs, `verify-pin*`, Gradle) | The maintainer's scope. The off-die ladder was still writing `ladder-off-final.log` during the move. The figures that matter are in the release notes, `RustyNES.qsf` and `salvaged/rc-v3.2.0-261014/`. | +| `scratchpad/{shots,dumps2}` frame dumps | Rendered from commercial dumps; regenerable with `RUSTYNES_DUMP_FRAMES=1`. | +| `scratchpad/{bindgen-cheevos,trunk-cheevos-dist}` | wasm-bindgen and trunk build output. | +| `/tmp/rustynes-atomic-*` (143 dirs), `/tmp/RustyNES/libretro-abi-*`, `/tmp/rustynes-coverage-collide-*`, `/tmp/rustynes-gamedb-overlay-contract-2`, `/tmp/kotlin-daemon.*.log` | Test fixtures and Gradle daemon logs; the next test run recreates them. | +| `/tmp/rustynes-baseline-screenshots` | 156 coverage captures at default frame counts, superseded by the committed `screenshots/external/` set (which uses the capture overrides), and derived from commercial dumps. | +| `/tmp/authy_build` (a git clone) and about 2,500 weak matches (another project's Python venv and the like) | Not this project's. | + +## 2026-10-14 — promotions out of `salvaged/` + +At the maintainer's request every file in the gitignored `salvaged/` was checked +for anything that belongs permanently in a repository. Each file was hashed and +looked up as a blob in both repositories' full history (2,963 of 3,448 matched +nothing); the 520 untracked scripts and sources were then read and sorted (99 +superseded, 411 one-off, 10 worth keeping). Promoted, each tested before +committing: + +| Now at | Was | +|---|---| +| `scripts/release-automation/run_gates.sh` | `salvaged/tools/deps-gates.sh` and the session gate runner | +| `scripts/release-automation/branch_contained.py` | `salvaged/tools/wtcheck.py`, extended to a branch's committed lines | +| `scripts/perf/quiet_ab.sh` | the v2.9.9 / v3.0.1 A/B queue scripts | +| `fuzz/fuzz_targets/movie_import.rs` | a v2.9.9 importer probe, rewritten as a libFuzzer target | +| `to-dos/plans/research/v3.1-line-survey-*.md` | the three v3.1 line-plan surveys, verbatim | +| `RustyNES_MiSTer`: `scripts/double-compile.sh`, `tb/mutate_gate.py`, `tb/export_golden.sh`, `tb/sweep_frames.sh` | the double-compile helper and three v3.2.0 mapper-work tools | + +The originals stay in `salvaged/`; its `README.md` records the triage. Logs, +evidence, bitstreams and the mislabelled ROM dumps stay local by design (each +dump is already named in `docs/mappers.md`, `CHANGELOG.md` or +`external_coverage.rs`). diff --git a/to-dos/plans/research/README.md b/to-dos/plans/research/README.md index abf5fa2c..53a56023 100644 --- a/to-dos/plans/research/README.md +++ b/to-dos/plans/research/README.md @@ -32,3 +32,22 @@ this tree is markdownlint-exempt like `ref-docs/`. | File | Scope | |---|---| | [`v2.0.0-research-master-clock.md`](v2.0.0-research-master-clock.md) | The one-clock + every-cycle-bus-access collapse, residual closure (R1–R5), Vs.DualSystem, breaking-API/save-state surface, perf re-baseline | + +## v3.1.0 → v4.0.0 — the line-plan surveys + +The three read-only surveys +[`../v3.1-to-v4.0-line-plan.md`](../v3.1-to-v4.0-line-plan.md) was drafted from +(2026-10-06/07, against `release/v3.0.1`). They lived only in session scratch +until v3.2.0 promoted them, because the plan carries their conclusions but not +their per-item evidence: of the 86 item ids in the oracle backlog's tables, 25 +appeared nowhere else in `docs/`, `to-dos/`, `ROADMAP.md`, `VERSION-PLAN.md` or +`CHANGELOG.md` when they were promoted (2026-10-14). **Statuses are as of 2026-10-06**; an item may since have +shipped under a ticket name (PROV-01 became T-NTSC-PROVENANCE). Unlike the dives +above, no reference-emulator source was consulted for these; the oracle survey +records the one incidental contact under PROV-02. + +| File | Scope | +|---|---| +| [`v3.1-line-survey-oracle-backlog.md`](v3.1-line-survey-oracle-backlog.md) | every open, deferred or proposed oracle item, with status, size, blocker and what it breaks | +| [`v3.1-line-survey-mister-backlog.md`](v3.1-line-survey-mister-backlog.md) | the same for the MiSTer sibling after v3.0.1 | +| [`v3.1-line-survey-ecosystem.md`](v3.1-line-survey-ecosystem.md) | the outside ecosystem to about 2027-10 (Android and iOS distribution rules, MiSTer submission, libretro), every claim source-labelled | diff --git a/to-dos/plans/research/v3.1-line-survey-ecosystem.md b/to-dos/plans/research/v3.1-line-survey-ecosystem.md new file mode 100644 index 00000000..aaaa0c4a --- /dev/null +++ b/to-dos/plans/research/v3.1-line-survey-ecosystem.md @@ -0,0 +1,530 @@ +# Ecosystem horizon for the v3.1 to v4.0 roadmap (2026-10-07 to about 2027-10) + +Read-only research, gathered 2026-10-06. Every claim carries a source. Labels: + +- **[V]**: verified against a primary source in this session. +- **[S]**: from a secondary source (press or blog) and not checked against the primary. +- **[I]**: my inference from a published pattern or cadence. It is not an announcement. +- **[U]**: could not be verified. + +No source of any other NES emulator or NES HDL was read. The repo was read only for context. + +--- + +## 0. Headline findings (the five that change plans) + +1. **The libretro build image stopped passing `-C ar` on 2026-09-03.** RustyNES's whole reason for holding the buildbot on 1.96.0 may already be gone. [V] + - Commit `841f3619` in `libretro-infrastructure/libretro-build-rust` is titled "Remove -C usage as no longer supported by rust". Commit `2956e008` followed it, and image pipeline `674682c8` was green on 2026-09-23. + - The Apple `CARGO_TARGET_*_RUSTFLAGS` now hold only properly separated `-C link-arg=...` flags. + - `rust-toolchain.toml`, AGENTS.md and `docs/agents/libretro.md` (dated 2026-10-06) still describe the image as injecting `-Car=...`. + - Confirm it by reading one Apple job log from the next RustyNES buildbot pipeline before unpinning. +2. **Android developer verification takes effect in 2027.** GitHub-sideload distribution is affected directly. [V] + - Enforcement started on 2026-09-30 in Brazil, Indonesia, Singapore and Thailand, on certified devices running Android 7+. + - The global rollout is "2027 and beyond". + - The free "limited distribution" account covers only 20 devices. Wider distribution needs Android Developer Console registration with government ID. + - Users can still install unverified apps through an "advanced flow". +3. **UniFFI main has merged zero-copy `&mut [u8]` (PR #2940), but it is not released yet.** [V] It unblocks MOB-06 at UniFFI 0.33, probably around Q4 2026 or Q1 2027 [I]. +4. **AccuracyCoin upstream added 2 new tests on 2026-10-05** (commit `9fc47e06`). There are also 6 other commits since RustyNES's last re-sync on 2026-09-19 (#528). [V] Those include the fix "Fixed an issue with Misaligned OAM Behavior". So "144/144" is now 144 out of a larger catalog. +5. **Android 17 (API 37) adds a mandatory `ACCESS_LOCAL_NETWORK` runtime permission** for apps that target it. [V] Android LAN netplay breaks silently unless the app requests it. No Play deadline for API 37 has been announced yet [V]. By the annual pattern it will be about 2027-08-31 [I]. + +--- + +## 1. Rust + +### Release schedule [V for released, I for future] + +Releases follow a 6-week train. The 1.100 and 1.101 dates come from releases.rs; the dates after that are extrapolated from the cadence. + +| Version | Date | Status | +|---|---|---| +| 1.96.0 / 1.96.1 | 2026-05-28 / 2026-06-30 | released | +| 1.97.0 / 1.97.1 | 2026-07-09 / 2026-07-16 | released; `-C ar` became a hard error here | +| 1.98.0 | 2026-08-20 | released | +| 1.99.0 | 2026-10-01 | **current stable**; RustyNES's pin | +| 1.100.0 | 2026-11-12 | in beta (releases.rs) | +| 1.101.0 | 2026-12-24 | in nightly (releases.rs) | +| 1.102 | 2027-02-04 | [I] | +| 1.103 | 2027-03-18 | [I] | +| 1.104 | 2027-04-29 | [I] | +| 1.105 | 2027-06-10 | [I] | +| 1.106 | 2027-07-22 | [I] | +| 1.107 | 2027-09-02 | [I] | + +### Stabilisations since 1.96 that are relevant here [V] + +- **1.97:** `isolate_highest_one`, `isolate_lowest_one`, `highest_one`, `lowest_one` and `bit_width` on integers and `NonZero`; const `char::is_control`. +- **1.98:** + - Algebraic float ops (`f32::algebraic_add`, `algebraic_mul` and the rest). These *permit* reassociation, so results can vary with optimisation. **Never use them in the deterministic core or BLEP synthesis.** + - `format_into` / `NumBuffer`; `Atomic::from_mut`. + - `std::range::legacy`, groundwork for the range-type edition change. +- **1.99:** + - C-variadic function definitions (`extern "C" fn(..., args: ...)` with `core::ffi::VaList`). + - `Vec::into_parts` / `from_parts`; `Box::into_non_null` / `from_non_null`; `size_of_val_raw` / `align_of_val_raw`. + - `String::from_utf8_lossy_owned`; `std::fs::set_times`. + - C-variadic definitions matter only if the RA or libretro log callbacks are ever implemented on the Rust side. They take a C `va_list`. + +### Portable SIMD (`core::simd`) + +Still **nightly-only** as of 2026 [S]. It is not in the 2026 Project Goals list [V]. + +- Do not plan around it in the v3.x to v4.0 window. +- Per-target `core::arch` intrinsics remain the only stable route. The repo has already measured and rejected the SIMD blitter. + +### Target-tier changes that touch RustyNES + +- **`i686-pc-windows-msvc` loses host tools** (RFC 3999, written 2026-07-02 [S]). The blog post "Demoting i686 Windows targets to std-only" appeared on 2026-10-02 [V, title only; the post URL 404'd]. + - It is std-only from here on, so cross-compiling *to* it still works. + - `i686-pc-windows-gnu` was already Tier 2 from 1.88 [V]. + - Relevance: the libretro buildbot's Windows i686 job (added in #554) and any 32-bit Windows desktop binary. +- **`x86_64-apple-darwin` was demoted to Tier 2** (RFC 3841) [S]. Intel-Mac binaries still build but get less testing. + +### Next edition (Rust 2027) + +Nothing official has been announced. [V] + +- rustc `main` (`compiler/rustc_span/src/edition.rs`, fetched 2026-10-06) has `Edition2015/2018/2021/2024/EditionFuture` and **no `Edition2027` variant**. `LATEST_STABLE_EDITION = Edition2024`. +- The 2026 Project Goals have no "Rust 2027 edition" goal. The closest is "Evolving the standard library API across editions" [V]. +- Community discussion (internals, 2025) says 2027 happens only if enough breaking work, such as the RFC-accepted range-type fix, is ready. Otherwise the edition slips to 2028 or later [S]. +- The 2024 edition existed as an unstable nightly edition for well over a year before it shipped in 1.85 [I]. By that precedent, **a 2027 edition shipping before late 2027 is unlikely**, and certainly not inside a v4.0 window that ends around Q3 2027 [I]. + +### Other 2026 project goals worth watching [V for names] + +`build-std`, "Stabilize cargo-script", "Const Traits", "Stabilize never type (`!`)", "Stabilize the next-generation trait solver", "Wasm Components" (a `wasm32-wasip3` target), and "Stabilize MemorySanitizer and ThreadSanitizer Support". + +- MSan and TSan would strengthen the fuzz and `emu-thread` gates if they stabilise. +- `build-std` would matter to the tvOS and `thumbv7em` jobs. + +### MSRV move? + +Nothing in 1.97 to 1.99 is needed by the core. Library MSRVs are the real driver: + +- egui 0.36.2 and wgpu 30 both declare `rust-version = "1.95"` [V, upstream `Cargo.toml`]. +- winit 0.31 beta declares 1.86 [S, lib.rs]. + +The binding constraint is the libretro buildbot pin. See section 3. + +**Implication for RustyNES:** +- Keep the workspace pin tracking stable on a cadence, for example every second stable, in a MINOR release. +- Add an explicit "no algebraic float ops in chip crates" lint or review rule. +- Do not schedule a Rust 2027 edition migration before v4.0. Re-check rustc for an `Edition2027` variant in about Q2 2027, and if one lands, plan the migration as a post-v4.0 MINOR. + +--- + +## 2. Graphics and UI stack + +| Crate | Latest stable | Cadence and next | RustyNES pin | +|---|---|---|---| +| wgpu | **30.0.1** (2026-08-21) [V] | README: "we release a breaking version every three months" [V]. v29 was 2026-03-19 and v30 was 2026-07-01, so **v31 is due now** (about early Oct 2026) [I]. Unreleased work includes `TextureViewDescriptor::swizzle` and a `DeviceDescriptor::default_queue` field [V]. Later majors: v32 about Jan 2027, v33 about Apr 2027, v34 about Jul 2027 [I]. | `wgpu = { version = "30", features = ["webgl"] }` | +| egui / egui-wgpu / egui-winit | **0.36.2** (2026-09-08) [V] | 0.34 Mar 27, 0.35 Jun 25, 0.36 Aug 5 (2026): a minor every 6 to 10 weeks [V]. egui `main` is still on `wgpu = "30.0"`, `winit = "0.30.13"` and `accesskit 0.25` [V]. **0.37 should track wgpu 31** [I]. No public egui roadmap was found [U]. | 0.36, with a **vendored `egui-winit`** (`vendor/egui-winit`) | +| winit | **0.30.13** (2026-03-02) stable; **0.31.0-beta.3** (2026-09-04) [S, lib.rs] | 0.31 has been in beta since about Nov 2025. No release date is published [U]. It is a large API break (the event-loop / `ApplicationHandler` reshaping continues). egui has not moved to it [V]. | `0.30` | +| cpal | **0.18.2** (2026-08-16) [V, CHANGELOG] | 0.18.0 (2026-06-06) added native PipeWire and PulseAudio backends, a unified `Error`, explicit `play()` and a 48 kHz default. 0.18.2 added `ErrorKind::Xrun`. `master` is already `0.19.0` (unreleased) [V]. | `0.18` | + +### WebGPU in browsers [V, gpuweb Implementation-Status, edited 2026-10-02] + +| Browser | WebGPU on by default in stable | +|---|---| +| Chrome | Windows, macOS and ChromeOS since 113. Android since 121 (GPU-dependent). Linux only on Intel Gen12+ (144) and NVIDIA on Wayland (147). Windows ARM64 is behind a flag. | +| Firefox | Windows since 141. macOS since 145 on Tahoe 26, and 147 on every macOS version. **Linux and Android are Nightly or Beta only.** | +| Safari | 26 on macOS, iOS, iPadOS and visionOS. | + +**Implication for RustyNES:** +- Budget one "graphics stack" MINOR per quarter: wgpu 31 plus egui 0.37 together. They co-move, and the vendored egui-winit must be re-diffed each time. +- Plan winit 0.31 as its own release only after egui adopts it, probably not before Q1 2027 [I]. +- Keep the WebGL2 fallback (`features = ["webgl"]`) for the whole v3.x line, because Firefox on Linux and Android and many Linux Chrome GPUs still lack WebGPU. +- A cpal 0.19 bump is a likely chore for the same window. + +--- + +## 3. libretro / RetroArch + +### The build image and `-C ar` [V] + +- The image repository is `git.libretro.com/libretro-infrastructure/libretro-build-rust` (files: `Dockerfile`, `Dockerfile.apple`, `Dockerfile.android`, `.gitlab-ci.yml`). The master pipeline builds `Dockerfile` and pushes it to `$CI_REGISTRY_IMAGE`. +- **2026-09-03, `841f3619` "Remove -C usage as no longer supported by rust":** replaced the `-Car=,Clink-arg=...` values with `CARGO_TARGET_*_AR`. +- **Then `2956e008` "Attempt #3 to fix rust":** removed the `_AR` variables and set clean values: + - macOS: `CARGO_TARGET_{X86_64,AARCH64}_APPLE_DARWIN_RUSTFLAGS="-C link-arg=-undefined -C link-arg=dynamic_lookup"` + - iOS and tvOS: the same, plus `-C link-arg=-rpath -C link-arg=` +- Later commits (2026-09-12 to 09-23): + - added `rustup` targets `aarch64-unknown-linux-gnu`, `i686-unknown-linux-gnu` and `i686-pc-windows-gnu`; + - added an i686 llvm-mingw toolchain (`CARGO_TARGET_I686_PC_WINDOWS_GNU_LINKER` / `_RUSTFLAGS="-L $LLVM_MINGW_STUB"`); + - added a `+nightly i686-pc-windows-gnu`; + - bumped `cargo-ndk` to 4.1.2. +- The latest master pipeline (`674682c8`, 2026-09-23) **succeeded**, so the published `:latest` image carries the fix. +- Caveats: + - Whether RustyNES's buildbot jobs pull that `:latest` has not been confirmed from a job log [U]. + - Those `RUSTFLAGS` now *reach the linker*. Under the old `-Car=` token they never did, as `docs/agents/libretro.md` explains. So an Apple job's link line changes even on 1.96. Watch the first pipeline. +- The `rust-apple.yml` template still uses `cargo +nightly` for tvOS and sets no `RUSTFLAGS` [V]. RustyNES already overrides that. +- **RetroArch releases:** the last tagged release is still **v1.22.2 (2025-11-20)** [V, GitHub API], although `libretro.h` changes continue on master. + +### Core options + +The RustyNES core still declares options through legacy `RETRO_ENVIRONMENT_SET_VARIABLES` (v0) [V, `crates/rustynes-libretro/src/lib.rs`]. + +- `SET_CORE_OPTIONS_V2` (with `GET_CORE_OPTIONS_VERSION` as a fallback) adds categories, sublabels, per-value labels and translations. It has been in `libretro.h` since 2021, and the libretro docs page encourages it [V for the header contents; docs page not re-fetched]. + +### New `libretro.h` APIs in 2025-2026 [V, RetroArch commit log for `libretro-common/include/libretro.h`] + +| Date | API | Relevance | +|---|---|---| +| 2025-09-11 | `GET_TARGET_SAMPLE_RATE` (optional) | Could let BLEP synthesis output at the frontend's rate and skip a resample. | +| 2026-02-05 | ROM memory type | Memory descriptors; RA tooling. | +| 2026-06-29 | `GET_AUDIO_SAMPLE_BATCH_FLOAT` (float output negotiation) | The mixer is float internally. This avoids an i16 quantisation step. | +| 2026-07-01 | `GET_MEMORY_STATUS` | Not needed. | +| 2026-07-18 | HDR10 output, XRGB2101010, `GET_SCREEN_10BPC_CAPABLE` | Not needed for an NES core. | +| 2026-08-11 | `GET_VFS_AUTHORIZED_LOCATIONS` | Low priority. | +| 2026-09-10 | `GET_AUDIO_SAMPLE_BATCH_MULTI` (multi-channel) | Optional, for per-channel APU export. | +| 2026-09-12 | VFS API v5 | Low priority. | + +Whether these new environment calls ship in a tagged RetroArch is unclear while 1.22.2 remains the latest tag [U]. Gate them on `RETRO_ENVIRONMENT_*` returning true. + +**Implication for RustyNES:** +- **Early in v3.1:** re-test the buildbot on 1.99, using a branch pipeline with `RUSTUP_TOOLCHAIN` removed. If all four Apple jobs link, retire the 1.96 split (`.gitlab-ci.yml`, the seven `rust-version = "1.96"` declarations, the `libretro-cross` 1.96 leg) and correct the three docs that describe the old image. +- **Later in v3.x:** add Core Options v2 and float-audio negotiation, both behind a capability check. + +--- + +## 4. RetroAchievements + +### Requirements and process [V, docs.retroachievements.org/general/hardcore-compliance-requirements.html, "last updated about 3 months ago"] + +- RA now uses the term **"hardcore-compliant"** rather than "supported" or "partnership". +- **Eligibility:** + - The emulator must be **publicly available for at least 6 months**. RustyNES is. + - FOSS licence obligations must be met. + - A **monetisation disclosure** is required. ADR 0035's no-monetisation decision makes this trivial. + - A **privacy policy** is required, covering data retention, server locations and GDPR. + - The emulator must disclose any earlier identification as another client. +- **Technical requirements:** + - Triggers, Measured and Rich Presence must work, and leaderboards must work and cannot be disabled. + - **Offline unlock queueing** with secure caching. + - Save-state hit storage is recommended. + - **Hardcore blocks:** cheats, rewind, slowdown and frame advance, *loading* save states, memory editors, debuggers, **scripting**, and **TAS input playback**. + - Switching to hardcore mid-session needs a full reset. +- **User-Agent format:** `EmulatorName/v1.0.0 (OSName 10.0) core_name/v0.5.0`. + - RustyNES sends `RustyNES/ rcheevos/` (`RA_USER_AGENT`). + - Check whether RA expects a `v` prefix and an OS segment, and whether `rcheevos/` is acceptable in the core slot [U]. +- **How to apply** is not stated on that page [U]. The forum post "A Note on RA Process Updates and the Afterplay Emulator" (topic 31930) returned HTTP 403 to fetch. +- `docs/ra-integration-request.md` in the repo is **stale**. It says v1.8.8 and "MIT OR Apache-2.0", but the project is GPL-3.0-or-later. + +### rcheevos cadence [V, GitHub releases] + +| Version | Date | +|---|---| +| 12.0.0 | 2025-06-22 | +| 12.1.0 | 2025-10-07 | +| 12.2.0 | 2025-12-21 | +| 12.2.1 | 2026-01-17 | +| 12.3.0 | 2026-02-10 | +| 12.4.0 | 2026-07-23 | +| **12.5.0** | 2026-09-14 (vendored) | + +That is a minor roughly every 2 to 5 months. Expect 12.6 or 13.0 around Q1 2027 [I]. + +**Implication for RustyNES:** +- Make an "RA hardcore-compliance" milestone in v3.x: + 1. audit `session_policy.rs` against the full block list (scripting and TAS playback especially); + 2. confirm offline queueing; + 3. write a privacy policy; + 4. fix the UA format; + 5. refresh the request template; + 6. then apply. +- Budget one rcheevos vendor bump per two quarters. + +--- + +## 5. App distribution + +### Google Play [V unless marked] + +- **Target API level:** API 36 is required for new apps and updates from **2026-08-31**, extendable to 2026-11-01. Apps below API 35 are hidden from new users on newer devices. + - RustyNES's `targetSdk = 36` and `compileSdk = 37` already comply [V, `android/app/build.gradle.kts`]. + - API 37 has no announced deadline yet [S]. By the yearly pattern it will be **about 2027-08-31** [I]. +- **16 KB page size:** required since **2025-11-01** for apps targeting 35+ with native code [S]. The repo already handles it (NDK r27+ note in `build.gradle.kts`). +- **New personal developer accounts** (registered after 2023-11-13) must run a closed test with **12 opted-in testers for 14 continuous days** before production access [S]. +- **Developer verification** applies to sideloads too [V, developer.android.com/developer-verification]: + +| Date | Milestone | +|---|---| +| Aug 2026 | APIs, limited-distribution accounts and the "advanced flow" available | +| 2026-09-30 | enforced in BR, ID, SG and TH (certified devices, Android 7+) | +| 2027+ | global | + + - Limited-distribution accounts: up to **20 devices**, no ID, no fee. + - Full registration through the **Android Developer Console** needs government ID. + - F-Droid published an open letter opposing the scheme (2026-02-24) [V, title only]. + - Press coverage frames it as a threat to emulator sideloading [S]. +- **Android 17 (API 37)**, released 2026-06-16 [S]. Changes that apply when targeting 37 [V, behavior-changes-17]: + - **`ACCESS_LOCAL_NETWORK` runtime permission** (in the `NEARBY_DEVICES` group) is mandatory for LAN discovery and connection. This affects RustyNES's direct-IP and LAN netplay. + - On large screens the orientation and resizability opt-out is removed. + - **Background audio hardening:** audio APIs fail *silently* outside a valid lifecycle. This also applies to all apps on Android 17. + - Native libraries loaded with `System.load()` must be read-only. + - `static final` fields cannot be modified through reflection or JNI. + +### F-Droid / IzzyOnDroid + +- F-Droid requires a 100% FLOSS toolchain, but "Rust toolchains are explicitly permitted as prebuilt binaries" [V, Inclusion Policy page, as summarised]. +- It allows no proprietary deps (Play Services, Firebase). The repo's `foss` flavour exists for exactly this. +- It has anti-feature labels (for example `NonFreeNet` if a RetroAchievements or hosted-netplay server is used) [V]. +- It has no emulator-specific rule [V]. +- Developer-signed APKs need reproducible builds [S]. Practical need: commit `Cargo.lock` (done) and use `cargo build --locked` [S]. +- **IzzyOnDroid** takes APKs from GitHub tagged releases [S]: + - 30 MB per-APK limit, 3 versions kept; + - reproducible-build verification for about 64% of its apps. + - This is the lowest-friction "store" for a sideload-first app. Check the APK size against 30 MB [U]. + +### Apple [V for the guideline text] + +- **Guideline 4.7** now reads: "retro game console **and PC** emulator apps can offer to download games". +- Apps offering such software must also meet 4.7.1 to 4.7.5: + - an index of offered software with universal links (4.7.4); + - an age-restriction mechanism (4.7.5); + - a report and block mechanism (4.7.1). + - These matter only if the app ever *offers* downloads. Loading user files does not trigger them. +- **SDK floor:** since **2026-04-28**, uploads to App Store Connect, **including TestFlight**, must be built with Xcode 26 and the iOS 26 SDK [S]. + - iOS 27 and Xcode 27 shipped 2026-09-14 [S]. + - By the yearly pattern, **Xcode 27 / iOS 27 SDK will be required about late April 2027** [I]. +- **TestFlight:** 10,000 external and 100 internal testers; builds expire after **90 days**; the first external build needs beta review [S]. +- **EU DMA:** AltStore PAL lets developers self-publish notarised apps for free through their own JSON "source" [S]. Apple's per-install Core Technology Fee was €0.50 [S]. Apple has since revised its EU terms (a "Core Technology Commission") [U, exact 2026 terms not verified]. + +**Implication for RustyNES:** +1. **Before 2027 (v3.x):** decide the Android identity question. Choose between full verification, the 20-device limited account, or relying on the advanced flow and IzzyOnDroid or F-Droid. F-Droid's own future is in doubt. +2. **Before about 2027-08 (v3.x MINOR):** add `ACCESS_LOCAL_NETWORK` handling and a foreground service for background audio, then target API 37. +3. **About April 2027:** rebuild iOS with Xcode 27. +4. **Every 90 days:** re-upload TestFlight builds, or testers lose the app. + +--- + +## 6. UniFFI (MOB-06) + +[V, `mozilla/uniffi-rs` CHANGELOG on `main`, and crates.io] + +| Version | Date | Bytes support | +|---|---|---| +| 0.30.0 | 2025-10-08 | — | +| 0.31.0 | 2026-01-14 | — | +| 0.32.0 | 2026-06-30 | **Zero-copy `&[u8]` / `[ByRef] bytes`** (read-only, foreign → Rust). **Breaking:** Kotlin callers must pass a *direct* `java.nio.ByteBuffer`. Not supported in async functions. | +| 0.32.1 | 2026-09-08 (changelog; crates.io says 09-09) | — | +| 0.32.2 | 2026-09-23 (crates.io; RustyNES's lock) | read-only borrows only | +| **Unreleased (main)** | — | **zero-copy `&mut [u8]` / `[ByMutRef] bytes`** (PR #2940), synchronous functions only. Swift `inout Data`, Kotlin direct `ByteBuffer`. Metadata contract version 31, so expect a minor bump to **0.33** [I]. | + +- **Cadence:** a breaking minor every 3 to 6 months. 0.33 is likely around **Q4 2026 to Q1 2027** [I]. +- MOB-06 also needs a redesign: `run_frame` currently *returns* `Vec` (`docs/audits/v2.9.9-frontend-reaudit.md`). It must change to fill a caller-owned buffer, `fn run_frame_into(&self, out: &mut [u8])`. + +**Implication for RustyNES:** schedule MOB-06 for the first v3.x MINOR after UniFFI 0.33 ships, gated on the Android and iOS device run. Prototype it against uniffi `main` beforehand to measure the GC-churn win. + +--- + +## 7. NES accuracy ecosystem + +### AccuracyCoin upstream [V, GitHub API] + +- RustyNES last re-synced on 2026-09-19 (#528, "both oracles reach upstream HEAD"). +- Upstream has 8 commits since then, through `9fc47e06` on 2026-10-05: + - 2026-09-22: "Prevented … DMC Sync", plus a rename from PPU "Data Bus" to "IO Bus"; + - 2026-09-23: "Prevented another potential infinite loop"; + - 2026-10-04: "Saved space and removed dead code"; + - 2026-10-05: "Fixed an issue with 'Misaligned OAM Behavior'"; + - 2026-10-05: "Prevented potential issues with the stack"; + - **2026-10-05: "Various cleanup, Added 2 new tests"**. +- Upstream is in an active burst, roughly weekly since August. The September commits added 2 new pages and 3 tests, which RustyNES already absorbed. + +### New suites in 2025-2026 + +No new standalone NES test-ROM suite announcement was found [U]. + +- The nesdev wiki "Emulator tests" page lists the established suites. +- The TASVideos "NESAccuracyTests" page (156 tests, last edited about 3 weeks ago) is a results table, not a new suite [V]. + +### TASVideos emulator acceptance [V, tasvideos.org/EmulatorResources/Requirements, last edit 2026-07-15] + +Minimum conditions are listed below. There is no published application procedure; acceptance is case by case [V]. + +| Area | Requirement | +|---|---| +| Movie files | Reproducible input files with a documented format; staff must sync full movies. | +| Gameplay | Must not deviate noticeably from the system; RNG and physics deterministic. | +| Start point | Explicit: power-on, SaveRAM or savestate. | +| Duration | Derivable from frame counts in the metadata. | +| Availability | Free, with **old releases kept available for historical replay**. | +| A/V | Internal dumping or OBS compatibility, A/V sync, native resolution, no frame skipping at any speed. | + +Which NES emulators are currently accepted was not re-verified [U]. The page `EmulatorResources/NES` returned 404. + +ADR 0045's `EMULATION_EPOCH` refuses movies recorded at another epoch. That fits TASVideos' model only if every past release binary stays downloadable, which GitHub Releases provides. + +**Implication for RustyNES:** +- **First v3.1 task:** re-sync AccuracyCoin, then triage the 2 new tests red-first. Any fix that moves output bumps `EMULATION_EPOCH`. +- Add a scheduled CI or agent check that diffs upstream HEAD monthly. +- A TASVideos acceptance request is plausible in v3.x. Prepare an `.rnm` format document and an A/V dump path, then ask on the TASVideos forum. + +--- + +## 8. Netplay hosting + +- **Signalling:** RustyNES already ships its own `signaling_server` binary (`rustynes-netplay`, feature `signaling-server`, tokio-tungstenite) [V, repo]. The gap is *hosting*, not code. + - **Cloudflare Workers + Durable Objects** (WebSocket Hibernation) work on the **free plan**, SQLite-backed objects only [V, CF pricing]: + - free: 100k requests/day, 13,000 GB-s/day, 5 GB storage; + - paid: $0.15/M requests and $12.50/M GB-s, with hibernation cutting duration charges sharply. + - The cost is a rewrite of the room protocol in JS or Rust-to-wasm on Workers. + - **Open-source alternative:** `matchbox_server` / `matchbox_signaling` (Rust; full-mesh signalling, GGRS-compatible sockets) [S, docs.rs]. +- **TURN:** **Cloudflare Realtime TURN** [V, CF docs]: + - $0.05/GB egress; 1,000 GB free per month, shared with the SFU; + - free and unlimited STUN at `stun.cloudflare.com:3478`; + - TURN over UDP 3478/443, TCP 3478/80 and TLS 5349/443; + - per-allocation rate limits. + - Rollback NES traffic is tiny. At about 60 packets/s of about 100 bytes, it is about 6 KB/s, or about 22 MB per player-hour [I]. **The free tier covers tens of thousands of relayed hours a month** [I]. + - Self-hosting coturn on a small VPS is the other option. Prices were not verified [U]. +- **How others do it:** + - RetroArch runs a public lobby list plus regional **MITM relay servers** that avoid port forwarding [S, retroarch.com/netplay.php]. + - Dolphin uses a "traversal server" for NAT hole-punching plus a lobby [U, from knowledge, not re-verified]. + +**Implication for RustyNES:** +- **v3.x:** decide whether to run a public lobby. The cheapest durable option is a Cloudflare Worker with Durable Objects for rooms, plus Cloudflare TURN with short-lived credentials minted by that Worker. +- That brings in a privacy policy (also needed for RA) and F-Droid's `NonFreeNet` anti-feature. + +--- + +## 9. MiSTer + +### Submission process [V, MiSTer-devel Wiki "Contributing a Core to MiSTer FPGA"] + +- The licence must be GPLv3, MIT or compatible. +- The repo must follow the standard template (`sys/`, `rtl/`, `releases/`, `.qpf`, `.qsf`). The core **must not depend on custom edits to `sys/`** framework files. +- Releases are `releases/CoreName_YYYYMMDD.rbf`. +- Submit by emailing the repo link to **newcores@misterfpga.org**. Review "can take upwards of a month". +- On acceptance the repo transfers to MiSTer-devel and you stay maintainer. Search results give an older address (newcores.misterfpga@gmail.com) and a step to add the core to the "List of Cores" with a unique home folder [S]. +- Alternatives to MiSTer-devel: JTCores, Coin-Op Collection, or a custom `downloader_.ini` database for `update_all`. +- Nothing says a second NES core is barred [U]. Expect reviewers to ask how it differs from the existing NES core. +- The RustyNES sibling repo is **private**, per CLAUDE.local. MiSTer-devel requires a public repo under a compatible licence. That is a prerequisite decision. + +### SuperStation One + +| Date | Status | Source | +|---|---|---| +| 2026-01 | First batches (Founders and crystal-blue editions) reached buyers in early January. | [S, Time Extension, 2026-01-05] | +| 2026-07-29 | "Console Mode" UI released (it had slipped from June): RetroAchievements, artwork scraping, per-game core overrides. Comments mention Batch 4. | [S, Time Extension] | +| — | The SuperDock (CD drive for PS1, Sega CD and PCE-CD) is in pre-order. | [S] | + +- **Specs:** Intel Cyclone V SoC FPGA; **128 MB on-board SDRAM**; HDMI up to 1440p/1536p; VGA, composite and component; Wi-Fi, BT, Ethernet; NFC (Zaparoo); 2 PS1 SNAC ports; M.2 [S, CNX Software]. +- The exact FPGA part (5CSEBA6 vs 5CSXFC6) and the SDRAM part number are still not public [U]. CLAUDE.local already tracks this as open. +- The on-board SDRAM is relevant to the off-die build. With it, the board does not need a MiSTer SDRAM add-on. + +**Implication for RustyNES:** +- Hardware verification (ADR 0043's v3.x) can target units that are shipping now. +- Sequence: board session → hardware-verified MINOR or MAJOR → decide repo visibility → MiSTer-devel email (allow 1+ month). +- An `update_all` custom database is a faster interim channel that needs no review. + +--- + +## 10. Other things a roadmap author would regret missing + +- **Android developer verification (2027 global)**: see section 5. This is the biggest external risk to the current sideload model. +- **Android 17 local-network permission and silent background-audio failures**: see section 5. Both bite the existing netplay and audio paths. +- **AccuracyCoin moving under the "100%" claim**: see section 7. Any public "144/144" statement should carry the upstream commit it was measured against. +- **wgpu's 3-month breaking cadence** means about 4 forced graphics bumps between now and v4.0 [I]. +- **`wasm32-wasip2`** is Tier 2 [S]. A `wasm32-wasip3` target is a 2026 goal [V, goal name]. Neither affects the browser build (`wasm32-unknown-unknown`). + - A WASI build of the headless core could serve as a sandboxed CI oracle or a plugin target. It is optional, and not on the critical path. +- **i686 Windows host-tool demotion**: see section 1. 32-bit Windows is a std-only target now. + - Keep 32-bit Windows artefacts only where a user base exists: the libretro buildbot builds it anyway. +- **Rust 2027 edition**: not imminent. See section 1. +- **RetroArch has not tagged a release since 1.22.2 (2025-11)**: see section 3. Any new-API adoption must degrade gracefully. +- **iOS SDK floor every April**: see section 5. Late April 2027 for Xcode 27 [I]. +- **Firefox WebGPU on Linux and Android, and Chrome on most Linux GPUs, are still not default** [V]. Keep WebGL2. + +**Implication for RustyNES:** add a recurring "ecosystem watch" item to every MINOR's plan. Cover: + +- the wgpu/egui pair; +- the AccuracyCoin upstream diff; +- the rcheevos version; +- the libretro image commit log; +- the Android and iOS policy calendar. + +Then pin three dated deadlines: + +1. 2027 Android verification; +2. about April 2027, the Xcode 27 floor; +3. about August 2027, API 37. + +--- + +## Sources + +Rust: +- +- (release index: 1.96.0 2026-05-28, 1.96.1 2026-06-30, 1.97.0 2026-07-09, 1.98.0 2026-08-20, 1.99.0 2026-10-01, "Demoting i686 Windows targets to std-only" 2026-10-02) +- +- +- +- +- +- +- +- +- +- +- +- + +Graphics: +- +- +- +- +- +- +- +- +- + +libretro: +- (commits `841f3619`, `2956e008`, `674682c8` and pipelines, via the GitLab API) +- +- +- + +RetroAchievements: +- +- +- (403 to fetch) +- + +Distribution: +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- +- + +UniFFI: +- (PR #2940, #2878) +- + +Accuracy: +- +- +- +- + +Netplay: +- +- +- +- + +MiSTer and SuperStation One: +- +- +- +- diff --git a/to-dos/plans/research/v3.1-line-survey-mister-backlog.md b/to-dos/plans/research/v3.1-line-survey-mister-backlog.md new file mode 100644 index 00000000..682f81f8 --- /dev/null +++ b/to-dos/plans/research/v3.1-line-survey-mister-backlog.md @@ -0,0 +1,369 @@ +# RustyNES_MiSTer — the backlog after v3.0.1 (input to a v3.1 → v4.0 roadmap) + +Compiled 2026-10-06. Read-only research: no file was edited, nothing was built +and Quartus was not touched. Sibling at `release/v3.0.1` (`65c5940`), oracle at +`release/v3.0.1`. + +**Firewall statement.** No third-party NES HDL and no reference-emulator source +was opened. The incumbent `NES_MiSTer` was consulted only through its README's +feature list (ADR 0040: public release metadata is outside the firewall). Oracle +files that carry `// Provenance:` headers were *listed* (`grep -rln`) and not +read; they matter to planning because those regions are black boxes for HDL +work (see §2.4). + +**Legend.** Size: S (one release item, < 1 week), M (a release's headline), +L (several releases). Dependency: `none` / `board` / `2nd board` / +`maintainer` / `upstream`. Bitstream: `Y` = changes the RTL, so it re-opens the +seed sweep at that release's build date and produces a new `.rbf`; `N` = +docs/tooling only. **UNVERIFIED** marks anything not confirmed at its source. + +**Baseline after v3.0.1.** Six mapper families (0, 1, 2, 3, 4, 7) +(`rtl/ines_header.sv:137-140`); ladder 199/0/1 on-die, 200/0/1 off-die at +v3.0.0 (`README.md` status table); v3.0.1 adds `mapper4mmc3oddskip080` and an +RTL fix (ledger 3.49), so v3.0.1 ships a new bitstream (`to-dos/plans/v3.0.1-mortar-plan.md:32`). +Fit (on-die, latest `output_files/RustyNES.fit.summary`, 2026-10-06, possibly +mid-sweep): **22,922 / 41,910 ALMs (55%), 468 / 553 M10K (85%), 33 / 112 DSP**; +off-die: 24,570 ALMs, **84 M10K (15%)**. **No hardware has run any bitstream.** + +--- + +## 1. Hardware verification (rung 6) + +Source of truth: oracle `to-dos/plans/v3.x-hardware-verification-plan.md` +(the gate, lines 30-40), `to-dos/plans/v2.9.x-final-audit-and-hardware-plan.md` +(Strands A-F, lines 136-211), sibling `docs/bringup.md`, `docs/bringup-log.md` +(the worksheet; every verdict cell is NOT RUN), `docs/HARDWARE_TESTING.md`, +`tools/stage_board_kit.sh`. The SuperStation One is in hand (sibling +`README.md` "Rung 6 -- hardware -- is open"); no DE10-Nano is. + +| ID | Item | Source | Size | Dep | Bitstream | +|---|---|---|---|---|---| +| HW-0 | **Stage the kit** for the release's own bitstreams: `tools/stage_board_kit.sh` (Tier 1 corpus in `games/NES/tests/` + `games/RustyNES/tests/`, MGLs, `MANIFEST.md5`, `EXPECTED.md` with the md5s). Re-stage whenever the `.rbf` changes | `tools/stage_board_kit.sh:1-50`; `bringup-log.md` A6 | S | none | N | +| HW-A1..A7 | **Strand A, stock core only**: reflash SD, `update_all`, INIs + `yc.txt` (+ `RustyNES_60.1=183251937963`), DIP state, 9 V/3 A PD, SSH + `/dev/MiSTer_cmd`, the eight baseline smoke rows on stock `NES_MiSTer` | `bringup-log.md` Strand A; `HARDWARE_TESTING.md` §2 | S (one session) | board | N | +| HW-A8 | **Device read**: does a 5CSEBA6U23I7 `.rbf` load at all (the stock core loading answers it); photograph the FPGA marking. If the SS1 is really 5CSXFC6D6F31, a second Quartus target, second seed sweep and second timing reads are needed — "the most expensive surprise available" | v2.9.x plan :147-151, :216-219; `HARDWARE_TESTING.md:51,60-66` | S to read; **L if wrong** | board | Y if wrong | +| HW-A9 | **SDRAM part read** (photo of the marking; chip count/topology), and DDR3 size. Only "128MB BGA SDRAM" is published | v2.9.x plan :113-117; `bringup-log.md` O1 | S | board | maybe (see HW-O6) | +| HW-B1..B9 | **Strand B, on-die build**, nine ordered steps, stop at first failure (menu entry + date column, load, sync, OSD, ROM load, right picture, sound, pad) | `bringup.md` "First boot"; `bringup-log.md` B1-B9 | S-M | board | N (Y for each fix it finds) | +| HW-C1..C4 | **Strand C, the four properties no gate reaches**: palette per-entry RGB vs FBX Smooth; H/V on a scope; DC-coupled resting audio level (the `FRAC` 8→15 fix never gated); band-limiting FFT (known-harsh by design) | `bringup-log.md` Strand C; `bringup.md` "exactly four things" | M (needs scope + capture) | board | Y likely (a palette/timing correction is RTL; plan :220-222 says budget a second compile cycle) | +| HW-C5 | **The v2.9.8 menu options** (aspect incl. `[ARC1]/[ARC2]`, integer scale, 224-line crop, palette select) seen on screen. Compiled, timing-closed, never seen | `emu.sv:138-149`; sibling `README.md` ("four properties are unverified by construction") | S | board | N | +| HW-D1 | **Tier 1 battery unattended**, read back as bytes via `$6000` (`tools/run_battery.sh`) | `bringup-log.md` D1 | S | board | N | +| HW-D2 | **AccuracyCoin mirror ROM** → `.sav` → entry-for-entry diff of the 144 scored entries | `bringup-log.md` D2 | S | board | N | +| HW-D3 | **Tier 2 A/B against stock `NES_MiSTer` on the same silicon** | `bringup-log.md` D3 | M | board | N | +| HW-D4 | **Soak ≥ 4 h**, passive cooling | `bringup-log.md` D4 | S (elapsed time) | board | N | +| HW-D5 | **Battery save across a power cycle** (`T-MISTER-SAVE`'s hardware acceptance) | `bringup-log.md` D5; ROADMAP `T-MISTER-SAVE` | S | board | N | +| HW-O1..O3 | **Off-die strand**: MemTest ≥ 10 min (a gate), then load `offdie/RustyNES.rbf` and repeat B4-B9 | `bringup-log.md` O1-O3; `bringup.md` "The off-die bitstream" | S-M | board | N | +| HW-O4 | **AUD-28**: photograph what a failed SDRAM probe looks like (console held in reset, OSD silent) so a message can be designed | `bringup-log.md` O4 | S, then S-M RTL for the message | board | Y (message) | +| HW-O5 | **AUD-29**: scope an SDRAM DQ edge; the vendored `sys/` pins carry no slew-rate assignment | `bringup-log.md` O5 | S | board | N (and `sys/` cannot be edited; any fix is upstream) | +| HW-O6 | **Replace the provisional SDRAM constraints** in `RustyNES.sdc` (written for AS4C32M16SB-7, zero board delay assumed) with the real part's numbers; re-sweep the off-die build | `bringup.md` step 1; `HARDWARE_TESTING.md:52`; README "still assuming zero board delay" | S-M | board (HW-A9) | Y (off-die) | +| HW-E1 | **`T-MISTER-DIRECTVIDEO`**: one assignment; acceptance = a picture on a CRT. SS1's ADV7125 + FPGA YC encoder make it testable with no I/O board | `bringup-log.md` E1; ROADMAP `T-MISTER-DIRECTVIDEO` | S | board + CRT | Y | +| HW-E2 | **`T-MISTER-MENUMASK`** presentation: is the greyed Reset entry rendered | `bringup-log.md` E2 | S | board | N | +| HW-F1 | **Re-measure the incumbent**: build current `NES_MiSTer`, run the *same* AccuracyCoin build | `bringup-log.md` F1; v2.9.x plan :201-208 | S-M | board | N | +| HW-F2 | Feature delta written honestly (`submission-case.md` table) — update once §2 work lands | `submission-case.md` "The feature delta" | S | none | N | +| HW-F3 | **Submit / not submit** | `bringup-log.md` F3 | — | maintainer | N | +| HW-X1 | **Flip every "no hardware has run" anchor**, located at the time by grep in both repos, in the same PR as the evidence | v3.x plan :44-47; `bringup-log.md` "The anchors" | S | board | N | +| HW-X2 | **Two-board claim**: `contribution-checklist.md:218` "both boards, one `.rbf`" must be split (SS1 tick + open DE10-Nano row) or left unticked with a reason; never ticked on one board | `bringup-log.md` "What rung 6 closing would mean"; v3.x plan :38-40 | S | maintainer / 2nd board | N | +| HW-X3 | **DE10-Nano run** of the same `.rbf` (Strand B at least). Needed only for the self-imposed two-board box; the MiSTer submission page does not require it (§4) | v3.x plan :66; `bringup.md` "Hardware" | M | 2nd board | N | +| HW-X4 | **The release itself**: re-cut after board fixes; the v3.x hardware-verified release (MINOR or MAJOR is open, ADR 0043 Decision 2) | v3.x plan :4-10 | M | maintainer | Y | + +**Ordering constraint (load-bearing):** a palette, video-timing or audio-level +correction from Strand C is an RTL change, which re-opens the seed sweep at that +day's `BUILD_DATE` (seed margin is build-date-scoped: README, the 260929 and +261003 failures). So the board session should be scheduled **before** any +feature RTL lands in the same release, and the hardware-verified bitstream is +the one compiled *after* the board's fixes. + +--- + +## 2. Feature breadth + +### 2.1 What exists today (established from the sibling) + +| Capability | State | Source | +|---|---|---| +| Mappers | 0 NROM, 1 MMC1 (≤ 256 KiB PRG; SUROM/SXROM refused), 2 UxROM, 3 CNROM, 4 MMC3 (rev A; NES 2.0 board-variant submappers — MMC6, MC-ACC, T9552 … — refused since v2.9.9), 7 AxROM | `rtl/ines_header.sv:137-140,193-206`; `emu.sv:174-184` | +| Cartridge size | on-die 256 K PRG / 128 K CHR; off-die 512 K / 256 K; trainers refused | `emu.sv:178-183` | +| Battery saves | yes (`FS1,NES` → `.sav`, `ioctl_upload_req`), since v2.6.21; hardware acceptance open (HW-D5) | `emu.sv:129-136`; ROADMAP `T-MISTER-SAVE` | +| OSD | aspect (4 incl. ARC1/2), scandoubler Fx (None/HQ2x/CRT 25/50%), scale (4), 224-line crop, palette (FBX Smooth, 2C03 RGB), Reset (menumask'd), info lines for refusals | `emu.sv:138-185` | +| Input | two pads (`J1,A,B,Select,Start`), `joystick_2..` and paddles/analog/ps2 tied off | `emu.sv:155-163,830-833,1285-1287` | +| Region | NTSC only. Clock enables are parameterised (16/5 expressible) but the PPU is NTSC-only | `emu.sv:547-548`; `submission-case.md` table; TASKS clock-divider entry | +| Video path | `video_freak` instantiated (v2.9.8); `direct_video` not wired; composite/NTSC encoder none | `emu.sv`; ROADMAP `T-MISTER-DIRECTVIDEO` | +| Audio | 2A03 only; lookup mixer + DC blocker; no band-limiting; no expansion audio | `bringup.md` table | +| Save states, cheats, FDS, NSF, Vs. System, Zapper, Four Score, paddle, keyboard, PAL | **absent** | v3.x plan :62-66 ("Deferred past v3.0.0"); `submission-case.md` table | + +### 2.2 What users expect of a MiSTer NES core (public feature list only) + +From `NES_MiSTer`'s README, read as a statement of features +(, fetched +2026-10-06): saves for most games; save states (4 slots); NTSC, PAL and Dendy; +FDS with expansion audio; multiple palettes; Zapper, Power Pad, microphone, +Miracle Piano; four players; "+8 sprites per line" option; expansion audio from +VRC6, VRC7, MMC5, Namco 163, Sunsoft 5B; up to 32 cheat codes; NSF player; and a +long mapper list (0-5, 7, 9-10, 12-13, 15-16, 18-19, 21-27, 30-34, 36-39, 44-48, +52, 64-82, 86-90, 92-95, 97, 101, 111-113, 118-119, 132-141, 143-160, 171-173, +180, 184-185, 189-191, 194-196, 205-211, 218, 225, 232, 243, 268, 413, 547), +including large homebrew (*Legend of Link*, *Rockman Minus Infinity*). + +### 2.3 Feature items + +| ID | Item | Source | Size | Dep | Bitstream | +|---|---|---|---|---|---| +| FB-1 | **More mapper families** — see the ranked list §2.4; each family = `cart.sv` decode + bus/checkpoint gates + mutation + a commercial-frame gate where a dump exists | TASKS.md:366 ("remaining ~185 … DEFERRED — out of the approved scope") | S-M each | maintainer (re-scope) | Y | +| FB-2 | **MMC1 SUROM/SXROM** (PRG A18 via CHR line; 512 KiB PRG): *Dragon Warrior III/IV*, *Final Fantasy I+II*. Needs the off-die build (on-die PRG cap is 256 K) | v2.9.x plan :72-81 (6 titles refused by both builds), S4 :99; `emu.sv:183` | S | none | Y | +| FB-3 | **MMC3 board variants** now refused: MMC3 rev B behaviour (`6-MMC3_alt`), MMC6 (*StarTropics*), TxSROM/TQROM (118/119) | `ines_header.sv` NR-14 comment `emu.sv:174-177`; `rung7-mappers.md:229-248` | S-M | none | Y | +| FB-4 | **Save states** (`T-MISTER-SAVESTATE`): framework `SS{base}:{size}` DDR3 region, 4 slots; core streams every register and memory incl. PRG/CHR-RAM in SDRAM. Oracle-gatable via a serialise/restore round-trip. "Largest capability gap" | ROADMAP `T-MISTER-SAVESTATE` (lines 691-711); v2.9.x plan S6 :101; | L | none (DDR3 bring-up; verify on board) | Y | +| FB-5 | **Rewind** (ring of states in DDR3, ~40-60 s) | v2.9.x plan S7 :102 | M | FB-4 | Y | +| FB-6 | **Cheats** (`T-MISTER-CHEATS`): CONF_STR `C`, framework `.gg` loader, fabric compare-and-replace; oracle-gated rung (apply a code both sides, compare the bus) | ROADMAP :713-731; plan S10 :105 | S-M | none | Y | +| FB-7 | **PAL and Dendy** (+ `T-MISTER-VMODE`): PPU PAL timing (312 lines, 3.2 dots/CPU), APU PAL tables, video timing, `new_vmode`. Oracle implements PAL; gates are exportable. Note oracle `frame_counter.rs` (PAL step table) carries a Provenance header → write from nesdev | ROADMAP `T-MISTER-VMODE`; TASKS clock-divider note; `submission-case.md` | L | none | Y | +| FB-8 | **Four Score / 4 players** (`T-MISTER-4PLAYER`): `joystick_2/3`, serial protocol in `controller.sv`; oracle models it; needs an exporter stimulus flag and a 4-port ROM | ROADMAP :659-669 | S-M | none | Y | +| FB-9 | **Zapper** (`T-MISTER-ZAPPER`): lightgun/mouse source; oracle has `Nes::set_zapper` with beam-relative light model; needs exporter stimulus | ROADMAP :680-689 | M | none (feel needs board) | Y | +| FB-10 | **Arkanoid paddle** (`T-MISTER-PADDLE`): `paddle_0`/`spinner_0`; *Arkanoid* is in the montage | ROADMAP :671-678 | S | none | Y | +| FB-11 | **Famicom extras**: Family BASIC keyboard (`T-MISTER-KEYBOARD`, `ps2_key`), P2 microphone, Power Pad, Miracle Piano (incumbent has the last three) | ROADMAP :804-810; incumbent README | S each | none | Y | +| FB-12 | **Expansion audio**: VRC6, VRC7 (OPLL), N163, Sunsoft 5B, MMC5, FDS. Each rides on its mapper (FB-1). Oracle `opll.rs`, `m019_namco163.rs`, `m069_sunsoft_fme7.rs`, `fds.rs` carry Provenance headers → RTL from nesdev only, oracle as black box | v2.9.x plan S8 :103; provenance list (§2.4 note) | M-L | FB-1 | Y | +| FB-13 | **FDS**: disk image in SDRAM/DDR3, 8 KiB BIOS on die, write persistence via HPS, disk-swap OSD | plan S9 :104 | L | none (off-die only realistically) | Y | +| FB-14 | **NSF player** | incumbent README; v3.x plan :64 | M | none | Y | +| FB-15 | **Vs. System** (incl. palettes, DIP OSD) | v3.x plan :64 | M | none | Y | +| FB-16 | **OSD breadth** (`T-MISTER-OSD`, ~119 status bits unused): region, overscan/border, "+8 sprites/line" (incumbent), custom `.pal` load, turbo (UNVERIFIED whether incumbent has turbo), `P` sub-pages. ROADMAP rule: options-surface work goes at the **start** of a release, before the sweep | ROADMAP :733-775 | M | none | Y | +| FB-17 | **Band-limited audio / NTSC composite simulation** — spend freed M10K off-die. Informed by HW-C4. Oracle `ntsc_bisqwit.rs`, `blip.rs` are Provenance-headered → doc-derived only | plan S8 :103 | M-L | HW-C4 | Y | +| FB-18 | **`yc.txt` line upstream** into `Main_MiSTer/yc.txt` (`RustyNES_60.1=183251937963`) | `HARDWARE_TESTING.md:134-161` | S | upstream | N | +| FB-19 | **Failed-SDRAM-probe message** in the OSD (AUD-28) | `bringup-log.md` O4 | S | HW-O4 | Y | +| FB-20 | **S2 slot-scheduled arbiter** + refresh in known-idle slots — prerequisite for any *added* SDRAM traffic (MMC5, large multicarts, FDS, save states touching SDRAM). PRG worst 22/24 cycles, CHR 18 after S1; a CHR residual of 23 vs 22-cycle budget in ~8 per 100k fetches remains | plan S2 :97; `submission-case.md` "The one thing"; `docs/sdram.md` §v2.9.1 | M | none | Y (off-die) | +| FB-21 | **S5 CHR prefetch / line buffer** (only if a mapper defeats S1+S2) | plan S5 :100 | M | FB-20 | Y | +| FB-22 | **Large multicarts** (mapper 268 to 32 MiB etc.) and big homebrew | plan fact 3 :68-71 | M | FB-20 | Y | +| — | Not recommended / infeasible: run-ahead, rollback netplay, HD packs, HD audio; RetroAchievements only in a third-party `Main_MiSTer` fork; TAS, Lua, debugger not planned | plan :106-111 | — | — | — | + +**Capacity note.** On-die M10K is at 85%; every feature that needs memory +(bigger carts, FDS, expansion-audio tables, line buffers) pushes toward the +off-die build. ALMs 55% and DSP 29% leave room for logic. That makes the +**headline-build question (S3)** an input to the feature roadmap, not an +afterthought: off-die runs 141 database titles the on-die build refuses +(plan fact 4 :72-81). + +### 2.4 Missing mapper families, ranked + +**Method.** Primary count: the oracle's per-game database +(`crates/rustynes-gamedb/src/game_database.txt`, 2,682 images, vendored from +TetaNES; one row per dump, so revisions and regions are counted separately), +measured here: total per mapper, and those with `(USA|Europe|World)` in the +title. Cross-check: the vendored nesdev wiki infobox "*N* in NesCartDB" +(`nesdev_wiki/.xhtml`; BootGod's NesCartDB). Known limitation: the +TetaNES DB files many Namco 108 games under mapper 4 rather than 206, so its 206 +count (2) understates; the wiki gives 39. The six implemented families account +for 2,170 of 2,682 images (1: 716, 4: 647, 2: 276, 0: 272, 3: 199, 7: 60), +matching plan fact 4. A public "percent of library" table with BootGod's exact +figures was **not** found in a fetchable page; one secondary source quotes MMC1 +33.4%, MMC3 23.4%, NROM 13.3%, UxROM 10.3%, CNROM 7.2% — **UNVERIFIED** +(search summary only). + +| Rank | Mapper(s) | Board | DB images (all / US-EU-World) | NesCartDB (wiki) | Notable titles | Effort | Notes | +|---|---|---|---|---|---|---|---| +| 1 | 1 (SUROM/SXROM) | MMC1 512 K | 6 refused | (in MMC1's 390) | *Dragon Warrior III/IV*, *Final Fantasy I+II* | S | off-die only (FB-2) | +| 2 | 206 (+88, 154, 76, 95 variants) | Namco 108 / DxROM | 2 (understated) | **39** | *Gauntlet*, *R.B.I. Baseball*, *Pac-Mania* (UNVERIFIED list) | S | MMC3 subset, no IRQ — reuses `cart.sv` MMC3 decode | +| 3 | 66 | GxROM / MHROM | 21 / 7 | — | *SMB + Duck Hunt*, *Dragon Power*, *Gumshoe* | S | discrete | +| 4 | 11 | Color Dreams | 46 / 45 | — | *Crystal Mines*, *Robodemons* (unlicensed US) | S | discrete; bus-conflict board | +| 5 | 79 (+113, 146) | AVE NINA-03/06 | 29 / 22 | — | *Dudes with Attitude*, *Pyramid* (unlicensed) | S | discrete | +| 6 | 9, 10 | MMC2 / MMC4 | 7 / 4 ; 2 | 1 ; 3 | ***Punch-Out!!***, *Fire Emblem* (JP) | S-M | CHR latch on PPU fetch; very high profile despite count | +| 7 | 5 | MMC5 | 25 / 9 | 15 | *Castlevania III*, *Uncharted Waters*, Koei titles | L | ExRAM, split screen, 1 MB, expansion audio; worst SDRAM pattern (needs FB-20) | +| 8 | 118, 119 | TxSROM / TQROM | 7 / 5 ; 4 / 4 | (MMC3) | *Armadinosaurs*, *NES Play Action Football*, *Pin Bot*, *High Speed* | S | MMC3 variants (FB-3) | +| 9 | 69 | Sunsoft FME-7 / 5B | 15 / 5 | 9 | *Batman: Return of the Joker*, *Gimmick!* (5B audio) | M | **oracle file Provenance-headered** | +| 10 | 19 | Namco 163 | 25 / 0 | 20 | Namco JP library | M | expansion audio; **Provenance-headered** | +| 11 | 16 (+153, 159) | Bandai FCG | 27 / 0 | — | *Dragon Ball* series (JP) | M | EEPROM; **Provenance-headered** | +| 12 | 21/22/23/25 | Konami VRC2/VRC4 | 23 combined / 0 | — | *Ganbare Goemon* (JP), *Wai Wai World* | M | | +| 13 | 18 | Jaleco SS88006 | 17 / 0 | 15 | Jaleco JP | M | | +| 14 | 71 (+232) | Camerica / Codemasters | 15 / 14 ; 6 / 5 | — | *Micro Machines*, *Fire Hawk*, *Dizzy*, *Quattro* carts | S | discrete | +| 15 | 34 | BNROM / NINA-001 | 3 / 2 | — | *Deadly Towers* | S | discrete | +| 16 | 64 / 158 | Tengen RAMBO-1 | 8 / 7 | 5 (158) | *Klax*, *Road Runner*, *Shinobi* | M | MMC3-like with CPU-cycle IRQ mode | +| 17 | 33 / 48 | Taito TC0190 / TC0690 | 16 / 0 | — | *Don Doko Don*, *Bubble Bobble 2* (JP) | S-M | | +| 18 | 210 | Namco 175/340 | 11 / 0 | 4 | Namco JP | S | | +| 19 | 24 / 26 | Konami VRC6 | 3 combined | 3 | *Akumajou Densetsu* (JP CV3), *Madara* | M | expansion audio | +| 20 | 85 | Konami VRC7 | 2 | 2 | *Lagrange Point*, *Tiny Toon 2* (JP) | L | OPLL FM; **`opll.rs` Provenance-headered** | +| 21 | 13, 87, 180, 185, 94, 140 | CPROM, J87, UN1ROM-type, CNROM-protect, etc. | 1-11 each | 1 (180) | *Videomation*, *Crazy Climber*, *Senjou no Ookami* | S each | trivial discrete; good batch filler | + +**Suggested next ~10 (coverage per effort, US/EU library first):** SUROM (1), +206 family, 66, 11, 79, 9/10, 118/119, 71/232, 34, then MMC5 (5) once the +arbiter (FB-20) exists. The Japan-only ASIC families (19, 16, 18, 21-25, 33, +210) follow; the expansion-audio boards (24/26, 69, 85, 19, 5) bundle naturally +with FB-12. + +**Provenance constraint on mapper work (ADR 0037 and AGENTS.md "THE FIREWALL IS +PER-REGION").** Oracle mapper files with `// Provenance:` headers (listed, not +read): `m016_bandai_fcg.rs`, `m019_namco163.rs`, `m035_jy_asic.rs`, +`m069_sunsoft_fme7.rs`, `m085_vrc7.rs`, `m099_vs_system.rs`, `m176`, `m244`, +`m250`, `m268`, `m513`, `fds.rs`, `kaiser.rs`, `mmc3_clones.rs`, +`multicart_discrete.rs`, `ntdec.rs`, `sachen_discrete.rs`, `unif.rs`, +`lib.rs`, plus `rustynes-apu/src/opll.rs`. For those families the RTL is written +from nesdev pages only, with the oracle used purely as an output comparator +(escalation ladder rungs 1-3). Re-run `grep -rln "^// Provenance:" crates` at +planning time rather than trusting this list. + +--- + +## 3. Open RTL items + +| ID | Item | Source | Size | Dep | Bitstream | +|---|---|---|---|---|---| +| RTL-1 | **`$2006` `v <- t` copy on a `v_pipeline` load dot** — does it match the oracle? Ordering confirmed (pipeline wins on a load dot, predates `v_pipeline`); divergence unmeasured. Needs a stimulus landing the copy on dot 257 or an increment dot, compared per cycle | `to-dos/mister/TASKS.md:345-351` | S | none | Y only if it diverges | +| RTL-2 | **`chrram-fetch` expected failure**: 18 of 185,725 fetches differ, all discarded dummy nametable fetches in the sprite window (picture byte-perfect). `run_xfail` graded both ways | `tb/regress.sh:562-565`; sibling `README.md` ("The read-side sibling `chrram-fetch`") | M (root cause) | none | Y | +| RTL-3 | **Ledger 3.19 — OAM corruption, OPEN asymmetry**: DUT implements all three seed windows from AccuracyCoin's prose; oracle arms only in dots 1-64. `ppuoamcorrupt052` is deliberately not a gate; mutations 3-4 NOT CAUGHT (stimulus blind). Needs an independent source (hardware: HW-D-class capture) | `docs/oracle-vs-documentation.md:1165-1230` | M | board (or new doc source) | maybe | +| RTL-4 | **Ledger 3.24 / 3.28 PARTIAL** — `$2007` read during rendering, ALE dot + octal latch; 3.29 cadence slot "inert, measured" | ledger :1462, :1676, :1736; index :112 | M | none | Y | +| RTL-5 | **PPU open-bus decay stimulus**: three mutations inert on the current ROM; the 558.7 ms constant is fitted (docs say ~30 ms, corpus forces ≥ 558.7 ms, ~17x incompatibility) | `TASKS.md:184-187`; ledger 1.1 :124-130 | S | none (hardware would adjudicate the constant) | N (gate only) | +| RTL-6 | **APU ledger 1.3 — power-on divider phase**, OPEN, "needs silicon" | `docs/apu-oracle-vs-documentation.md:93` | S | board | maybe | +| RTL-7 | **`ppuoamcorrupt052`** stays at 357,360 of 357,361 cycles (attributed allowance, fails both ways) — closes with RTL-3 | `README.md` ("The one attributed difference") | — | RTL-3 | — | +| RTL-8 | **Denied checkpoint stream**: `mapper4mmc3irq065`'s rolling hash denied (oracle records mapper /IRQ as one end-of-cycle value) — an oracle trace-format item | README status table, "Checkpoint streams not compared" | S | oracle | N | +| RTL-9 | **CHR SDRAM residual** (23 vs 22-cycle budget, ~8 per 100k) — closes with FB-20 | `submission-case.md`; `docs/sdram.md` | — | FB-20 | Y | +| RTL-10 | **15 AccuracyCoin sub-test ROMs unregistered** in `regress.sh` (no resolved suite/test address) | `TASKS.md` v2.6.21 entry ("The other 15 are unregistered") | S | none | N | +| RTL-11 | Ledger index items closed at v3.0.1 for reference: 3.49 (dot-0 A12 rule), odd-frame exception stimulus — **done**; `CLAUDE.local.md` still lists "odd-frame A12 stimulus" as OPEN, which is stale | `TASKS.md:335-363`; v3.0.1 plan :32 | — | — | — | + +--- + +## 4. MiSTer upstream submission + +### 4.1 What the requirements are now — and they changed + +The project's requirement record is `ref-docs/2026-08-23-mister-core-contribution-requirements.md` +(fetched 2026-08-23), which quotes "preservation value", the AI-code bar +("minimum reasonable bar for readability … evidence of quality and accuracy +testing"), the email, the invitation and the **repository transfer**. + +**The live wiki page was last edited 2026-09-26 and reads differently** +(, +fetched 2026-10-06 via a summarising fetch, so exact wording is **UNVERIFIED** +until read by hand): + +- Still required: GPLv3/MIT; template layout (`sys/`, `rtl/`, `releases/`, `.qpf + .qsf .srf .sdc .sv files.qip clean.bat .gitignore`); no custom edits to `sys/` + ("repo admins should be able to maintain your core by dropping in a fresh + `sys`"); follow the development principles; at least one fully functional + release `_YYYYMMDD.rbf`; send the repo link to + `newcores@misterfpga.org` "once testing is complete". +- **Absent from the fetched text:** "preservation value", duplicate/redundant + cores, the "minimum reasonable bar" sentence, the invitation + repository + transfer step, and the Home-folder/Cores-list step. +- **New AI passage:** "There is quite a bit of AI-Assisted code in mister-devel + already. We are totally fine with developers using AI to do some of the heavy + lifting"; the requirement is that the developer understands the code and will + maintain it. +- **No board requirement** — no DE10-Nano testing mandate, no specific board. + +**Development principles** (): +tabs not spaces (the RTL is 100% tab-indented, measured); "comment when +appropriate … avoid excessive documentation"; aligned formatting; `_n` suffix +for active-low; `(* multstyle = "logic" *)` if DSP runs out. **The comment +guidance is a real review risk**: `ppu2c02.sv` is 162 KB and `cpu6502.sv` +101 KB with essay-length comments — a maintainer decision (§7). + +Template_MiSTer HEAD is still `3ea1134c` (`git ls-remote`, 2026-10-06) — the +vendored `sys/` is current; no re-vendor needed today. + +### 4.2 Items + +| ID | Item | Source | Size | Dep | Bitstream | +|---|---|---|---|---|---| +| SUB-1 | **Write a dated supplemental `ref-docs/` file** recording the 2026-09-26 wiki change, read by hand (ref-docs is immutable) | §4.1 | S | none | N | +| SUB-2 | **Re-scope the checklist** to the live page: retire or re-word "preservation value", the AI-bar box (`contribution-checklist.md:223`), and the transfer box (`:293`) if the step is truly gone; keep the self-imposed two-board box split per HW-X2 | `contribution-checklist.md:218-317` | S | SUB-1 | N | +| SUB-3 | Checklist box "Runs on real hardware, both boards, one `.rbf`" | `contribution-checklist.md:218` | — | board (+2nd board) | N | +| SUB-4 | "AI-generated-code bar" box: link `submission-case.md` in the email | `contribution-checklist.md:223-243`; `submission-case.md` | S | HW-F3 | N | +| SUB-5 | **Refresh `submission-case.md`**: its quote of the AI bar and the "may be declined as a duplicate" framing are based on the August page; add the F1 incumbent number; update the feature delta as §2 lands | `submission-case.md:20-30,93-100` | S | HW-F1, SUB-1 | N | +| SUB-6 | **Email `newcores@misterfpga.org`**; await review | checklist :288-291; TASKS.md:466 | S | maintainer (after HW gate) | N | +| SUB-7 | **Repository transfer to MiSTer-devel** (one-way) — confirm whether it is still part of the process | checklist :293; TASKS.md:469 | — | maintainer, upstream | N | +| SUB-8 | Add to the Cores list with Home folder `RustyNES` (`/media/fat/games/RustyNES`, settled) | checklist :298 | S | upstream | N | +| SUB-9 | **Private repo**: the sibling is PRIVATE (`CLAUDE.local.md` v2.9.3 note). A submission needs it public — **maintainer decision**, and a pre-publication sweep (no commercial ROM-derived captures, `goldens/` stays ignored, firewall CI green) | `CLAUDE.local.md`; `README.md` "captures … not committed" | S-M | maintainer | N | +| SUB-10 | **Fallback homes** if declined: Retro Remake / SS1 distribution; openFPGA / Analogue Pocket (`nes_top.sv` kept framework-free for this) | checklist :308-317 | L (Pocket port) | maintainer | Y (new target) | +| SUB-11 | `yc.txt` line into `Main_MiSTer` alongside the submission (= FB-18) | `HARDWARE_TESTING.md:161` | S | upstream | N | +| SUB-12 | Release naming: MiSTer distribution uses the datecoded `releases/RustyNES_YYYYMMDD.rbf` (on-die only); the off-die build is a GitHub-release asset only — decide whether upstream ever gets an off-die build | `submission-case.md` "It is not in `releases/`" | — | maintainer | N | + +--- + +## 5. Tooling and CI debt (sibling) + +| ID | Item | Source | Size | Dep | Bitstream | +|---|---|---|---|---|---| +| TL-1 | **Verilator skew**: CI pinned to `ubuntu-26.04` (Verilator 5.032) at v3.0.1; dev machine runs **5.052**; `ubuntu-latest` was 5.020 and moves to 26.04 in Nov 2026. Lint verdict belongs to the CI version (v2.6.5 lost a release cut to 5.050 vs 5.020). Confirm the 26.04 move is merged and green; consider a second lint leg at the dev version | `.github/workflows/ci.yml:18-24`; `.github/scripts/install-verilator.sh` | S | none | N | +| TL-2 | **Quartus 17.0.2 is local-only**: CI cannot build a bitstream; reproducibility rests on one machine (wine for some tools). `HARDWARE_TESTING.md` §3.1(b) recommends a pinned containerised Quartus — not done. 17.0.x is MiSTer's required version (StudioII_MiSTer's `AGENTS.md` per `submission-case.md`) | `HARDWARE_TESTING.md:262-286` | M | none | N | +| TL-3 | **CI runs a subset**: 9 rung-1 gates vs a pinned oracle + module gates; the ~200-gate ladder needs ~2.1 GB of goldens and runs manually from frozen worktrees. Options: a self-hosted runner, cached goldens, or a nightly | `ci.yml` "rung 1 vs a pinned oracle (a SUBSET…)"; `submission-case.md` "Nine gates run in CI" | M | maintainer (runner) | N | +| TL-4 | **Seed sweep cost per RTL change**: margin is build-date-scoped; every feature release needs a 1-8 seed sweep × 2 builds + two clean compiles each. Budget it as fixed per-release overhead; consider automating the sweep + selection into one script run | README seed history; `scripts/seed-sweep.sh`; ROADMAP `T-MISTER-OSD` sizing rule | S (process) | none | — | +| TL-5 | **Stale planning docs** in the oracle: `to-dos/mister/SPRINT_PLAN.md` ("version numbers STALE"; "None of the DUT gates run in the sibling repository's workflows" — now nine do) and `IMPLEMENTATION_PLAN.md` (titled "v2.5.1 → v2.7.0", SDRAM figures from v2.6.16). Replace with the v3.x roadmap | those files :1-53, :1-77 | S | none | N | +| TL-6 | **Untracked working-tree clutter**: `tb/regress-frozen{,-2611,-2612,-2619}.sh`, eleven `tb/obj_dir_*` build trees, `tb/scratch/`, root `dq_*.rpt`, `c5_pin_model_dump.txt`, `sweep-logs*/` (none tracked; `git ls-files` = 231). Decide which become tracked tooling (a frozen-worktree runner) and clean the rest **after** the running sweep finishes | `ls tb`, `git ls-files` | S | none | N | +| TL-7 | **Golden recipes**: `ppuspr0` hand-built ROM committed as the one `.gitignore` exception; `ppumin`/`ppuspr0` lacked recipe rows (`CLAUDE.local.md` "13 of 110 golden stems" note — the v2.8.4 record says all 116 regenerate; re-measure) | `CLAUDE.local.md` Open list; `docs/golden-fetching.md` | S | none | N | +| TL-8 | **`emu.sv` is in no testbench** — the probe/presence wiring is gated only by Quartus and lint; HPS-side protocols (save, future save states/cheats) need a protocol model or real `hps_io` under Verilator (`-Wno-PROCASSWIRE` decision) | `bringup.md` step 2; ROADMAP `T-MISTER-SAVE` option analysis | M | maintainer (model vs real `hps_io`) | N | +| TL-9 | **agy reviewer**: OAuth 403 "insufficient authentication scopes" open at v3.0.0 | `CLAUDE.local.md` top | S | maintainer | N | +| TL-10 | `tb/check_reset_wiring.py` strips `//` but not `/* */` in its sinks count (harmless) | `CLAUDE.local.md` v2.8.3 carry-in | S | none | N | + +--- + +## 6. The SuperStation One — public facts + +| Fact | Value | Source | +|---|---|---| +| Maker / status | Retro Remake (Taki Udon). First units shipped just before Christmas 2025; batches 1-2 shipped early 2026; batch 3 pre-orders in 2026. Retro Remake's page still shows "Preorder" | Notebookcheck, 2026-01-07 ; | +| Price | Founders Edition $149.99; SuperDock ~ $50 (launch figures) | search summary of Notebookcheck/PCGH — **UNVERIFIED** current price | +| FPGA | Retro Remake: "Cyclone V FPGA" only. CNX Software: Cyclone V **SX 5CSXFC6D6F31I7N**. This build targets **SE 5CSEBA6U23I7**. A bitstream is device- and package-specific, and stock `update_all` cores are reported to run, so one claim is wrong — **UNVERIFIED until the marking is photographed (HW-A8)** | ; `HARDWARE_TESTING.md:51,60-66` | +| SDRAM | "128MB SDRAM" (BGA, soldered). Part number, chip count, topology unpublished. Single SDRAM only | retroremake.co page; v2.9.x plan :113-117 (nerdlypleasures 2026-07) | +| DDR3 | unpublished — **UNVERIFIED** | — | +| Video | HDMI up to 1536p/1440p (above 1080p capture dongles — `bringup.md` §"read the video mode back"), VGA, DIN10, composite, component; 24-bit ADV7125 DAC; Sony encoder (CXA2075 per Main's source) + FPGA YC encoder; four DIP switches select video mode | retroremake.co; `HARDWARE_TESTING.md:53,165-207` | +| Audio | 3.5 mm analog, TOSLINK | retroremake.co | +| I/O | USB-C PD power (9 V/3 A recommended, not a Switch 1 charger), 3× USB-A, Ethernet, microSD, dual PS1 SNAC ports (SNAC bypass switch on the dock), expansion slot (SuperDock: M.2 NVMe, optional disc drive) | retroremake.co; `HARDWARE_TESTING.md:54-58` | +| JTAG | internal header (case must be opened) | `HARDWARE_TESTING.md:55` | +| Software | Runs stock MiSTer OS; Retro Remake publishes an SD-card installer (mr-fusion based) that "boots into MiSTer FPGA"; Retro Remake documentation states update_all is safe | ; `bringup.md` closing section | +| Known quirks for cores | factory SD cards with overlapping partitions (reflash even new units); marginal PD supplies mimic SDRAM corruption; DIP state a hidden variable for analog tests; `yc.txt` needs a per-core line; default HDMI mode may be "auto" from EDID | `HARDWARE_TESTING.md` §2; `bringup.md` | +| Not established | a "console mode" firmware feature was reported (Notebookcheck) — page returned 403; misterfpga.org forum returns 403 to automated fetching | **UNVERIFIED** | + +--- + +## 7. Proposed order of work + +**Phase H — the board (v3.1.0, or a MAJOR; ADR 0043 Decision 2).** +HW-0 → Strand A incl. HW-A8/A9 (device + SDRAM part, first, because a wrong +device answer is an L-sized re-target) → Strand B on-die → Strand C → Strand D → +off-die strand + HW-O6 (real SDRAM constraints) → E1/E2 → F1. Fold every fix in +(each simulation-reachable finding becomes a gate first), re-sweep seeds at the +release date, re-run the failed strand and every later one, flip the anchors +(HW-X1), split the two-board box (HW-X2). Ship the hardware-verified release. +No feature RTL in this release. + +**Phase S — submission prep, in parallel with H (docs only, N).** +SUB-1, SUB-2, SUB-5, TL-5; decide SUB-9 (public repo) and the comment-density +question. Submit (SUB-6) only after Phase H's gate and F3's decision. + +**Phase F1 — cheap breadth (v3.2).** Options-surface first, then mappers: +FB-16 core options (region placeholder off, +8 sprites if decided, custom palette) +at the start of the release; FB-2 SUROM; the discrete/MMC3-subset batch (206 +family, 66, 11, 79, 71/232, 34, 118/119, trivial discretes); FB-10 paddle; +FB-8 Four Score; FB-6 cheats (oracle-gated rung). All Y; one seed sweep per release. + +**Phase F2 — the memory platform (v3.3).** FB-20 slot arbiter (+ RTL-9), DDR3 +bring-up, FB-4 save states (then FB-5 rewind). Decide the headline build (S3) +here, since everything later is off-die-heavy. + +**Phase F3 — the big boards and audio (v3.4-v3.6).** MMC2/MMC4, MMC5 (needs +F2), FME-7/5B, N163, VRC2/4/6, then VRC7; FB-12 expansion audio with each; FB-9 +Zapper; FB-11 Famicom peripherals. + +**Phase F4 — region and media (v3.7-v3.9).** FB-7 PAL/Dendy + VMODE; FB-13 +FDS; FB-14 NSF; FB-15 Vs. System; FB-17 band-limited audio / composite +simulation (informed by HW-C4). + +**v4.0 — feature parity milestone** (candidate definition): save states, +cheats, PAL/Dendy, FDS + expansion audio, Zapper, Four Score, and the mapper +families covering the incumbent's licensed-library list; DE10-Nano verified +(HW-X3) if a second board exists; re-measured incumbent comparison. + +Open RTL items RTL-1, RTL-5, RTL-10 are S-sized and slot into any release's +start; RTL-2/RTL-4 are investigation items; RTL-3/RTL-6 wait on the board. +Tooling TL-1 now; TL-2/TL-3 before Phase F2 (the gate count will grow fast); +TL-6 after the current sweep finishes. + +## 8. Decisions the maintainer must take + +1. **Version of the hardware-verified release**: v3.1.0 or a new MAJOR (ADR 0043 Decision 2; v3.x plan :7-10). +2. **Whether to buy/borrow a DE10-Nano**, or formally re-scope the two-board box (HW-X2/X3). The live MiSTer page does not require one. +3. **Submit to MiSTer-devel at all** (F3), after F1's same-build incumbent measurement — and whether the 2026-09-26 page change (no "preservation value"/duplicate language found) alters the framing in `submission-case.md`. +4. **Make the sibling repository public** (prerequisite to any submission or fallback home). +5. **Repository transfer** to MiSTer-devel if accepted and still part of the process (one-way). +6. **Comment density** vs MiSTer's "avoid excessive documentation" principle: keep the essay comments, move them to `docs/`, or accept the review risk. +7. **Re-open the mapper scope**: TASKS.md:366 records "the remaining families" as *out of the approved scope*, a decision rather than a backlog item. Approve a new scope (e.g. the next ~10 in §2.4) and the order. +8. **Which build is the headline** (S3): on-die (runs on a bare DE10-Nano, 85% M10K, refuses 141 DB titles) vs off-die (needs SDRAM; required for most growth). And whether upstream `releases/` ever carries the off-die build. +9. **Feature priority** among save states, PAL/Dendy, FDS, cheats, peripherals, expansion audio (the incumbent has all). +10. **Hardware-dependent RTL policy**: if the board contradicts the oracle (palette, video timing, RTL-3 OAM corruption, RTL-6 APU phase), which wins, and does the oracle change too (ADR 0037 escalation, possibly a new ADR). +11. **Escalation for Provenance-headered families** (N163, FME-7/5B, VRC7/OPLL, Bandai FCG, FDS, Vs.): doc-only RTL with black-box comparison is the default; rung 4 (reading the derived oracle region) requires an ADR 0037 amendment naming the authoriser. +12. **CI investment**: a self-hosted runner or cached goldens for the full ladder (TL-3), and a containerised Quartus (TL-2). +13. **`hps_io` verification strategy** for HPS-facing features (TL-8): protocol model vs real `hps_io` under Verilator with suppressed lint. +14. **Fallback homes** (SUB-10): pursue openFPGA / Analogue Pocket or SS1 distribution regardless of MiSTer's answer? diff --git a/to-dos/plans/research/v3.1-line-survey-oracle-backlog.md b/to-dos/plans/research/v3.1-line-survey-oracle-backlog.md new file mode 100644 index 00000000..95afd981 --- /dev/null +++ b/to-dos/plans/research/v3.1-line-survey-oracle-backlog.md @@ -0,0 +1,357 @@ +# RustyNES oracle backlog: every open, deferred or proposed item (input to the v3.1 → v4.0 roadmap) + +Read-only survey of `release/v3.0.1` (head `1bf6f14f`, 2026-10-06). v3.0.0 +"Cornerstone" is the current release; v3.0.1 "Mortar" is in progress. Every +status below was checked against the tree, the CHANGELOG or GitHub. A source +document's own status word was not trusted on its own. **UNVERIFIED** marks +anything this pass could not settle. + +**Firewall note.** No reference-emulator source and no third-party NES HDL was +opened. One incidental contact is reported under PROV-02: a TODO grep across +`crates/` matched comment lines inside the vendored TriCNES C# tree. Those lines +are not reproduced here and were not used. + +**Legend.** + +- **Status:** + - OPEN: real and not done. + - PARTIAL: some of it shipped. + - SHIPPED: done (where is cited). + - OBSOLETE: the premise no longer holds. + - BY-DESIGN: a deliberate non-target. + - MANUAL: code is complete and only a human, device, account or hosted deploy can verify it. +- **Size:** S is under a day, M is days, L is a release or more. +- **Blocker:** nothing, hardware, device, upstream, maintainer decision, or external (dumps, hosting, accounts). +- **Breaks:** the effect on compatibility. + - "none" means additive or off by default. + - "behaviour" means emulation output changes. ADR 0045 then requires an `EMULATION_EPOCH` bump, which refuses older movies and netplay peers. That has been done in a patch (v3.0.1 raised the epoch 1 → 2), so it is not by itself a MAJOR trigger in current practice. + - "state" means a save-state section version moves. + - "movie" or "netplay" means a format or protocol bump. + - "API" means a public Rust API break, which is a MAJOR trigger under `VERSION-PLAN.md`. + +--- + +## 1. Accuracy + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| ACC-01 | **PAL/Dendy emphasis: the red and green bits are not swapped** | `crates/rustynes-ppu/src/emphasis.rs:62-63`; ROADMAP T-EMPHASIS-MODEL | OPEN. The module docs say "Not modelled, by choice: ... the PAL/Dendy swap of the red and green bits". On a 2C07 every PAL game that sets emphasis shows the wrong tint. The NESdev NTSC/PAL pages document the swap | S-M | nothing (docs exist); the sibling has no PAL | behaviour (PAL/Dendy only) + epoch; palette goldens | +| ACC-02 | Emphasis: differential phase distortion and inter-pixel colour artifacts | `emphasis.rs:62`; ROADMAP T-EMPHASIS-MODEL ("open beyond it, as choices") | OPEN (a design choice, not a defect) | M | maintainer decision | none if opt-in; behaviour if default | +| ACC-03 | PPU register write placement: M2-low vs phi2, half a dot early | `docs/accuracy-ledger.md:50` | BY-DESIGN for now: "Known divergence, measured and deliberately NOT corrected (v2.6.17, maintainer decision)". The best combination tried reached 142/144 against the shipping 144/144. A default-off `phi2-write-sweep` knob is kept, and the reopen conditions are in the v2.6.17 plan | L | maintainer decision to reopen | behaviour + state + goldens | +| ACC-04 | Sprite-0 stale-shifter / internal-vs-external bus split | DEFERRED §6b; `docs/nesdev-hardware-emulation-checklist.md:82`; diagnostic `tests/stale_sprite_shift_regs_probe.rs:106` (`#[ignore]`) | OPEN. The catalogue's own condition stands: attempt it only if a real game needs it | M-L | maintainer decision / a motivating game | behaviour + state | +| ACC-05 | FDS `$4030.D1` DRAM-refresh watchdog IRQ | `docs/accuracy-ledger.md:46` | OPEN as an "honest residual, tracked not asserted". Public hardware research has not pinned the behaviour | M | external (hardware research) | behaviour (FDS only) | +| ACC-06 | APU analog HPF/LPF chain: measured-RC model | `docs/accuracy-ledger.md` "APU analog HPF/LPF chain" row | OPEN, optional ("No stricter oracle (optional measured-RC future work)") | S-M | nothing | none if opt-in | +| ACC-07 | **Vs. DualSystem real-cabinet boots** | `tests/vs_dualsystem.rs:188-209` (4 boots ignored: the dumps are the maincpu half only), `:280` (the combined Wrecking Crew dump never reaches a title screen in 60 s); the KNOWN_BLANK list holds 10 Vs entries | OPEN. Two halves: (a) combined 64 KiB dumps of Vs. Tennis and Vs. Mahjong (missing locally, so external); (b) why the combined Wrecking Crew dump does not reach attract mode, an investigation that can be done now (`docs/audit/vs-dualsystem-combined-dumps-2026-07-02.md`) | M | (a) external dumps; (b) nothing | behaviour (Vs. only) | +| ACC-08 | **KNOWN_BLANK ratchet: 52 staged dumps boot blank** | `crates/rustynes-test-harness/tests/external_coverage.rs:198-252` | OPEN. It is a ratchet in both directions: a newly blank boot fails, and so does a listed dump that starts rendering. Contents: m015 ×6, m034 ×2, m036 ×4, m068 ×2, m162 ×3, m185 ×3 (the Sansuu 1-3 Nen titles), m227 ×2, Vs/GVS ×10, *Gradius II (VC)*, *Akumajou Dracula (Disk Writer)*, *Chu Liu Xiang* and others. The m153 entry is correct behaviour (it waits for a reset on zeroed WRAM). The list is mostly hacks, conversions and multicarts, but it is the single largest real-game compatibility backlog | L (in aggregate; each S-M) | nothing (dumps are local) | behaviour per fix; epoch | +| ACC-09 | *Chu Liu Xiang* (mapper 176 FS005, headered as m30) renders no tiles | `docs/mappers.md:574`; DEFERRED §7; in KNOWN_BLANK | OPEN. The recorded hypothesis is that the mirroring register powers up at `$00` (vertical), not at the header value. The next step is a CPU trace | S-M | nothing | behaviour (m176.2) | +| ACC-10 | 2A03 revision H (`Rp2A03H`) "unexpected DMA" direction unverified | `docs/STATUS.md:728`; ADR 0033 | OPEN. H is byte-identical to G today, and the direction of the modelled difference is unverified | S (once measured) | hardware | behaviour (opt-in revision only) | +| ACC-11 | Mapper 233 "Unknown Multicart 1" (*Unknown Multi Cart w-Galaxian*) | `docs/mappers.md:478` | BY-DESIGN: "Not implementable from documentation". The NESdev page says the cart does not follow its own description | — | external (a document) | — | +| ACC-12 | R1: MMC3 `mmc3_test_2/4` sub-test 3 ("IRQ sooner") | DEFERRED §6b (still `[ ]`) | **SHIPPED, so the catalogue is stale.** v3.0.0 closed it as T-MMC3-BG-A12. `tests/mmc3.rs:119-120` `mmc3_test_2_4_scanline_timing_strict` is a plain `#[test]`. ROADMAP T-MMC3-BG-A12 says "Both ROMs pass all 13 sub-tests" | — | — | — | +| ACC-13 | By-design non-targets | `tests/mmc3.rs:133` (NEC rev B), `:219` (the v1 alternate-revision ROM); `tests/cpu_reset.rs:73,82` (an interactive reset); `docs/compatibility.md:153` (the PlayChoice-10 Z80 menu); DEFERRED §6d | BY-DESIGN, confirmed. Option, not a fix: the NEC MMC3 variant is now selectable by NES 2.0 submapper (v2.9.6). A per-game or config override could let `mmc3_test_2/6` run under NEC without changing the default | S (the override only) | maintainer decision | none | +| ACC-14 | Expansion-audio absolute level, VRC7 FM | `docs/accuracy-ledger.md` (VRC7 FM row) | "No stricter oracle". Nothing to tighten against | — | external | — | + +## 2. Mappers + +Tier picture, from `crates/rustynes-mappers/src/tier.rs:107-170`, the authority: + +- **191 families: 51 Core / 109 Curated / 31 BestEffort.** That agrees with `docs/STATUS.md:2012`. +- **BestEffort** is 29, 39, 50, 81, 104, 154, 174, 179, 238, 243, 261, the 16 NES 2.0 high-id boards (268 286 289 290 299 301 303 305 306 312 320 336 348 349 366 513), and v2.9.6's 47, 121, 191 and 194. +- **Submapper overrides** (`tier.rs:76-106`): 176.2 and 91.1 are BestEffort; 4.3 (MC-ACC) is Curated. + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| MAP-01 | **BestEffort → Curated promotion candidates** | `tier.rs`; ADR 0011; the precedent is 4.3, promoted on six local dumps at v2.9.7 | OPEN. Families with a booted local dump already pinned by an `external_coverage` snapshot:
- 47 (`mapper_047_SpikeVBall`)
- 121 (5 snaps)
- 191 (4 snaps; the v2.9.8 non-power-of-two fix)
- 290 (1 snap)
- 154 (*Devil Man*, filed under `mapper_088_*`)
- 243 (the Sachen set, filed under m150)
Curated requires a byte-identity boot snapshot in `external_extended.rs`. Whether each of these clears that bar is **UNVERIFIED**. 50's only dump is in KNOWN_BLANK, so 50 does not qualify | M | maintainer (whether non-redistributable local dumps count as Curated evidence; the F3 and MC-ACC precedents say yes) | none (the tier is an honesty marker) | +| MAP-02 | Families with no dump at all | `tier.rs:139-161`; ROADMAP T-MAPPER-DUMPS ("Mappers 194 and 195 still have no dump"); DEFERRED §7 | OPEN. 29, 39, 81, 104, 174, 179, 238, 261, 194, 195 and the 16 high-id boards have register-decode tests and nothing more. `tests/roms/external/mapper-029-RET-CUFROM/` is empty | S each | external (legal or homebrew dumps) | none | +| MAP-03 | **Missing families with known titles** | `crates/rustynes-mappers/src/tier.rs` (`_ => None`); `tests/external_coverage.rs:171-178` refuses two staged *Doraemon* hacks as "mapper 8 (FFE), not implemented" | OPEN. Mapper 8 (FFE) is demonstrated by staged dumps. Notable ids absent from `parse` (checked against the tier match, which `tier.rs:62` says is kept in lockstep with `parse`):
- licensed: 157 Datach, 158 Tengen 800037, 207 Taito X1-005 variant
- common pirate: 116, 114/182/215, 197, 165, 172/173, 230, 235, 252
Demand for each is **UNVERIFIED**. The policy for taking one is `docs/compatibility.md` "Long-tail mapper policy" | L (incremental, S-M each) | nothing (NESdev pages) | none | +| MAP-04 | Malformed dumps (CPROM ×3, m58 *Study and Game*, m146 *Lucky 777* ×2, a non-iNES Vs hack) | DEFERRED §7 ("unadjudicated") | **SHIPPED, so the catalogue is stale.** All are refused by name with a typed reason in `UNSUPPORTED`, `tests/external_coverage.rs:141-182` (v2.9.6) | — | — | — | +| MAP-05 | ASIC/BMC continuation toward ~300 families, and "100% TASVideos" | DEFERRED §7 `[~]` and `[ ]` | OPEN as long-tail with no version. 191 families at v3.0.0 | L | nothing | none | +| MAP-06 | Stale `docs/mappers.md` "Open questions" | `docs/mappers.md:1009-1015` | OBSOLETE text. MMC5 audio and VRC7 FM both landed, and "Pirate mappers: none in initial scope" is false at 191 families. Belongs under DOC | S | nothing | none | + +## 3. Platforms and mobile + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| MOB-01 | **The mobile device run** | `docs/mobile-v2.9.3-run-sheet.md` (moved after v3.0.0, maintainer 2026-09-29); line plan "After v3.0.0 — Mobile" | OPEN. 60 rows, none run on a device: B1-B2, D1-D5, T1-T12, M1-M7, A1-A15 and I1-I19. Only the emulator pre-run column is filled. The run sheet says B1's compile half has run in CI since v2.9.8, unsigned, for the Simulator.
Closing the run also closes:
- AUD-10 and AUD-14, CHANGED-UNVERIFIED (`docs/audits/v2.9.2-full-audit-disposition.md:29,33`; device rows I16-I18)
- AUD-12, "FIXED (untested)" (`v2.9.9-frontend-reaudit.md:134`; row A14)
- the Swift half of NF-21 (`frontend-disposition.md:132`; rows M5-M7)
- the DualSystem, FDS/NSF and SOCD rows T1-T12 (v2.9.7)
The older `to-dos/v1.8.x-on-device-verification.md` still has 26 open boxes. It is probably superseded by the sheet: **UNVERIFIED**, so retire it or fold it in | L | device + maintainer | none | +| MOB-02 | **iOS signing / TestFlight upload not provisioned** | `docs/ios.md:288-306` (the upload is "gated on the signing secrets", and the cron is dormant until `IOS_SIGNING_READY`) | OPEN, probably. `gh secret list` and `gh variable list` on `doublegate/RustyNES` both returned nothing. This may be a permissions limit of this session (**UNVERIFIED**), but if they are truly unset, "ships to TestFlight" (AGENTS.md) is not happening | S (provisioning) | maintainer (an Apple Developer account and App Store Connect API key) | none | +| MOB-03 | App-store, Google Play and F-Droid listings | ADR 0035; `docs/android.md:6`; line plan "After v3.0.0 — External" | OPEN. Scheduled "after the v3.x hardware-verification release". The `foss`/`play` flavours exist (`android/app/src/{foss,play}/`). Builds are currently sideload APKs plus (nominally) TestFlight | M | maintainer (accounts, scheduling) | none | +| MOB-04 | MOB-06: per-frame `Vec` copy across UniFFI, about 15 MB/s | `docs/audits/frontend-disposition.md:51`; `v2.9.9-frontend-reaudit.md:95` | OPEN, "DEFERRED (blocked upstream)". `uniffi 0.32.2` has read-only borrowed bytes only | M | upstream (a UniFFI `&mut [u8]` release) | bridge API (generated Kotlin/Swift bindings change) | +| MOB-05 | AND-02 remainder: the GPU-path pack skip | `v2.9.9-frontend-reaudit.md:70` ("GPU-path pack skip still deferred") | OPEN | S-M | nothing | none | +| MOB-06 | Android debugger surface | DEFERRED §9 `[~]` | OPEN, unscheduled. No debugger or disassembler appears in `android/app/src/main/java` (rg). iOS has `ios/RustyNES/DebuggerView.swift` | M | maintainer (wanted?) | none | +| MOB-07 | **iOS feature gaps the v1.9.9 readiness record deferred "post-Timebase"** | `docs/ios-v1.9.9-readiness.md:141-143` | Mixed:
- home-screen widget: OPEN (no WidgetKit; Android has `ResumeWidget.kt`)
- external-display output: OPEN (no external-screen code)
- box art: OPEN (`ios/RustyNES/ContentView.swift:194` "box art is a future maintainer task"; Android has `BoxArt.kt` and `ScraperSources.kt`)
- 20-band EQ: OPEN on both mobile apps (no EQ in `ios/`, `android/` or the bridge)
- cheats database: OPEN on both (`CheatsView.swift` takes manual codes)
- `.dbg` source maps: OPEN
- crash reporting: **SHIPPED** (`ios/RustyNES/CrashReporter.swift`, Android `Crash.kt`)
- FDS/NSF: **SHIPPED** v2.9.7, but the device run is pending | M each | maintainer (scope) | none | +| MOB-08 | Android Kotlin TODOs that are still live | grep | OPEN, small:
- APU PCM is not muxed into screen capture (`Capture.kt:33,172`)
- save-state thumbnails do not use a live framebuffer (`States.kt:86`)
- library tile long-press menu (`Library.kt:280`)
- the list-detail rail (`MainActivity.kt:1993`)
- a 3-way merge UI for cloud saves (`play/CloudSave.kt:42`, `MainActivity.kt:2638`)
- deeper folder nesting on import (`LibraryImport.kt:97`)
- Tink key rotation (`SecurePrefs.kt:31`)
- `TODO(i18n)` literals (`res/values/strings.xml:9`, `MainActivity.kt:2201`)
- a box-art fallback source (`BoxArt.kt:27`)
The v2.9.7 plan asked for "triage all 13", and these remain | S-M each | nothing | none | +| MOB-09 | Mobile 3-4 player netplay | see NET-01 | OPEN (mesh not wired into the bridge either: `rg MeshHost crates/rustynes-mobile` is empty) | M | nothing | none | + +## 4. Libretro + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| LR-01 | `libretro/docs#1215` (the v3.0.0 page update) | CLAUDE.local; `gh pr view 1215 -R libretro/docs` | OPEN: `"state":"OPEN"`. The `.info` PR (libretro-super#2131) merged | S | upstream review | none | +| LR-02 | Libretro buildbot pinned to Rust 1.96 | CHANGELOG [Unreleased] "Rust 1.99" (the image passes `-C ar`, a hard error from 1.97) | OPEN. The workspace is on 1.99 (`rust-toolchain.toml:2`); the seven libretro crates keep `rust-version = "1.96"` | S | upstream (the buildbot image) | none | +| LR-03 | Vs. DIP-switch core option | `crates/rustynes-libretro/src/lib.rs:862-868` ("a DIP core option is not implemented") | OPEN | S | nothing | none | +| LR-04 | More buildbot targets (ARM/RPi, Emscripten, consoles) | `docs/audits/libretro-disposition.md:28` (L-3.2 "SCOPE") | OPEN as a scope choice. `libnx` was dropped at v2.9.8 | M each | maintainer | none | +| LR-05 | Turbo and SOCD in the core | `libretro-disposition.md:34` (L-S1 "DECLINED") | BY-DESIGN: RetroArch provides both | — | — | — | +| LR-06 | `to-dos/libretro/` task list | `to-dos/libretro/TASKS.md` | SHIPPED (every box `[x]`). The files are historical | — | — | — | +| LR-07 | Vendored `rust-libretro-sys` patch | `vendor/rust-libretro-sys`, `Cargo.toml:44` | OPEN while vendored. Whether an upstream release now carries the full `retro_game_info` binding (L-1.2) is **UNVERIFIED** | S | upstream | none | + +## 5. Desktop frontend and UX + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| FE-01 | **CPU-multiplier overclock** | DEFERRED §6c | OPEN. `rg cpu_multiplier\|CpuMultiplier` finds nothing. The scanline overclock shipped (v2.9.7) | M-L | nothing | none by default. It must ride in `HardwareOptions` for movies and netplay (ADR 0044), probably with an `.rnm` format and `PROTOCOL_VERSION` bump. `HardwareOptions` is `#[non_exhaustive]` since v3.0.0, so the field itself is not an API break | +| FE-02 | **"Disable sprite limit"** | DEFERRED §6c; `crates/rustynes-frontend/src/config.rs:1797`; `debugger/settings_panel.rs:1937-1942` (shows `SetEnhSpriteInert`) | OPEN. The UI persists the flag and nothing reads it, and `rustynes-ppu` has no hook (no `sprite_limit` match). The overflow flag and evaluation timing must stay exact, so this is render-only | M | nothing | none by default; movie/netplay option carriage as FE-01 | +| FE-03 | A/V recording sync control | DEFERRED §1 `[~]` | PARTIAL. Codecs shipped (v1.8.9 `av_record.rs`); there is no sync field (`rg av_sync` is empty) | S | nothing | none | +| FE-04 | Multi-Disk Bundler: named FDS and multicart slots | DEFERRED §1 `[~]` | PARTIAL. The Disk Side submenu shipped (`ui_shell.rs:1015`); named slots and multicart handling are absent | S-M | nothing | none | +| FE-05 | Drag-and-drop ROM loading in the browser | `crates/rustynes-frontend/src/app.rs:10303-10306` ("file drops are a follow-up") | OPEN | S | nothing | none | +| FE-06 | Desktop/wasm migration to the `HostWarning` codes | DEFERRED §9 | OPEN, optional (`rg HostWarning crates/rustynes-frontend/src` is empty). The two stay in parity through `HostWarning::message()` | S | nothing | none | +| FE-07 | Rebindable Power Pad, microphone and keyboard keys | `crates/rustynes-frontend/src/input.rs:734,759,788,806` | OPEN | S-M | nothing | none (config) | +| FE-08 | Raise emu-thread priority on Windows and macOS | `crates/rustynes-frontend/src/emu_thread.rs:1008-1014` | OPEN ("`SetThreadPriority(ABOVE_NORMAL)` is the follow-up") | S | nothing (Windows has no `unsafe` budget issue; it is FFI) | none | +| FE-09 | **Vs. DualSystem advanced features** (run-ahead, rewind, netplay, TAS, debugger, HD packs) | ADR 0032; `docs/compatibility.md:146-149`; CHANGELOG 2.9.7 ("remain unavailable in dual mode") | BY-DESIGN today; L to lift | L | maintainer decision | none | +| FE-10 | DualSystem in the `wasm-canvas` embed | `crates/rustynes-frontend/src/wasm.rs:188-193` (main console only, with a warning) | OPEN. `wasm-winit` has had DualSystem since v2.9.7 | S-M | nothing | none | +| FE-11 | Region override; CRC32 as a secondary DB key | `docs/compatibility.md:346-349` "Open questions" | **SHIPPED**: per-game `region` in `per_game.rs:83,111`, and `rustynes-gamedb` keyed by `rom_crc32` (`lib.rs:925`). The doc is stale (see DOC-02) | — | — | — | + +## 6. Creator tools, TAS, debugger and scripting + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| CR-01 | Lua WebSocket client (`comm.ws*`) | `crates/rustynes-frontend/src/script_host.rs:497-499` (reports a clean closed state; "a maintainer follow-up") | OPEN | M | nothing (needs a ws crate) | none | +| CR-02 | Lua memory-mapped file backed by real OS shared memory | `script_host.rs:451-453` | OPEN. Today it is an in-process `HashMap` | M | nothing | none | +| CR-03 | **Non-controller inputs absent from the movie stream** (expansion devices, the Zapper, the microphone, Vs. coins/service, FDS disk events) | ADR 0044 "Neutral" | OPEN. TAS of Zapper, Vs. and FDS titles cannot be recorded faithfully | M-L | nothing | movie (an `.rnm` format bump); possibly netplay (the input stream) | +| CR-04 | wasm Lua (`piccolo`) per-access callbacks are no-ops | ADR 0012 | BY-DESIGN | — | — | — | +| CR-05 | Virtual pad, input macros, BasicBot, detachable tool windows, Batch Runner, FDS firmware manager, `userdata` SQLite | DEFERRED §1-2 | SHIPPED (the catalogue's citations were re-checked: `virtual_pad.rs`, `input_macros.rs`, `basic_bot_panel.rs`, `detached.rs`, `bin/batch_runner.rs`, `fds_firmware.rs`, `userdata_sqlite.rs`) | — | — | — | + +## 7. Netplay + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| NET-01 | **Native 3-4 player netplay is not reachable** | `crates/rustynes-frontend/src/netplay_ui.rs:241-245` ("the native UDP layer currently completes the first joiner's handshake") | OPEN. `crates/rustynes-netplay/src/mesh_net.rs` (v2.6.0: `MeshHost`, `MeshJoiner`, `UdpMeshTransport`, the roster) exists, but `rg MeshHost` in `rustynes-frontend` and `rustynes-mobile` finds nothing. The browser WebRTC path is 2-4 player (`docs/netplay-webrtc.md:42`) | M | nothing | none (the `Roster` message already exists) | +| NET-02 | A live 2-4 player browser WebRTC matrix | DEFERRED §3 `[M]`; `docs/netplay-webrtc.md:4,42-43` ("live verification pending") | MANUAL | M | external (a hosted deploy) + maintainer | none | +| NET-03 | Real cross-NAT UDP and TURN | DEFERRED §3 `[M]`; `docs/netplay-webrtc.md` §2.3; `crates/rustynes-netplay/tests/{stun,turn}_probe.rs` (`#[ignore]`) | MANUAL | S | external | none | +| NET-04 | Hosted netplay signalling (the `deploy/` stack: signalling, Caddy, coturn) | line plan "After v3.0.0 — External" | OPEN. The stack is turn-key; nothing is hosted | M | maintainer (hosting, domain) | none | +| NET-05 | Option changes during a session are detected but not prevented | ADR 0044 "Neutral" | BY-DESIGN (the desync checksum catches it) | — | — | — | + +## 8. RetroAchievements + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| RA-01 | Browser RA auth-proxy deploy | DEFERRED §4 `[M]`; ADR 0015 | OPEN. `crates/rustynes-frontend/web/cheevos/ra_glue.js:40` is still `RA_PROXY_BASE = ""`. The service side has been code-complete since v2.1.10 (`deploy/Dockerfile.raproxy`) | S-M | external hosting + maintainer | none | +| RA-02 | Live browser verify with a real RA account | DEFERRED §4 `[M]` | MANUAL (after RA-01) | S | maintainer | none | +| RA-03 | Allowlisting by the RA team | DEFERRED §4; line plan "External" | OPEN. The UA `RustyNES/ rcheevos/` is already sent (`rustynes-cheevos/src/http.rs`) | S | external (the RA team) | none | + +## 9. Audio and video + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| AV-01 | Human verification of A/V recording playback, shader/NTSC output, HD-audio (``/``), the v1.5.0 GPU timestamp crash fix, and egui render/pointer events | DEFERRED §5, §10 `[M]` | MANUAL, unscheduled. Since v2.9.7 the release binaries are the `full` build (CHANGELOG 2.9.7), so `av-record` and `hd-pack` reach users and this now matters | S-M | maintainer (GPU, audio device) | none | +| AV-02 | Per-APU-channel volume | DEFERRED §5 ("intentional v2.0 deferral") | SHIPPED, probably. A "Channel volume" setting exists (`debugger/settings_panel.rs:1436`, `i18n.rs:690`). That it is the item the catalogue meant is **UNVERIFIED** | — | — | — | +| AV-03 | PAL emphasis swap and emphasis distortion | see ACC-01, ACC-02 | — | — | — | — | + +## 10. Performance + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| PERF-01 | **Palette-workload offset (+4-5%) localised to `33ea0572`** | `docs/performance.md:1194-1212` ("Found and localized; not fixed"); `to-dos/plans/v3.0.1-mortar-plan.md` item 1 (pending a quiet host) | OPEN. The next step is one A/B, `8f449691` → `33ea0572`, then a byte-identical fix | S (measure) + M (fix) | a quiet host | none (it must be byte-identical) | +| PERF-02 | The `≤ 2 ms/frame` aspiration (about 3.95 ms is measured) | AGENTS.md; `docs/performance.md` | BY-DESIGN: not a gate, and accuracy is not to be traded | — | — | — | +| PERF-03 | MOB-06 UniFFI copy | see MOB-04 | — | — | — | — | + +## 11. CI, tooling and quality + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| CI-01 | Enable the GitHub merge queue | DEFERRED §11 | OPEN. `.github/workflows/ci.yml:29` has `merge_group:`. The only ruleset, "Protect (Default)", has the rule types `["deletion","non_fast_forward","required_status_checks"]` and no `merge_queue` (read via `gh api` on 2026-10-06) | S | maintainer decision | none | +| CI-02 | **`EMULATION_EPOCH` bump enforced by hand only** | ADR 0045 Consequences ("a check that ties the epoch to a fingerprint of a fixed panel of outputs would enforce the rule"); `docs/agents/ci-and-release.md:38` | OPEN. There is no epoch or fingerprint test in `crates/rustynes-test-harness/tests` or `.github/`. The epoch is `crates/rustynes-core/src/hardware_options.rs:98` (`= 2`) | M | nothing | none | +| CI-03 | Feature-combination clippy is enumerated, not generated | DEFERRED §11 (closed by enumeration) | Residual risk: a new feature is unlinted until someone adds a line. `cargo-hack --mutually-exclusive-features` could cover it, which is **UNVERIFIED** against this workspace | S | nothing | none | +| CI-04 | Firewall hygiene for searches | (this survey) | OPEN. `rg` over `crates/` descends into the vendored TriCNES source (`crates/rustynes-test-harness/golden/tricnes/tricnes-{full,harness}-src/`, among 67 tracked files there). An `.ignore`/`.rgignore` entry would keep agent sweeps out of it. See PROV-02 | S | nothing | none | +| CI-05 | `macos-15-intel` runner sunset (August 2027) | `to-dos/ROADMAP.md:496-505` | **OBSOLETE**. `x86_64-apple-darwin` was dropped in v1.6.0 (ADR 0009; `.github/workflows/release.yml:72-79`), and v3.0.1 moved the macOS jobs to `macos-15` (CHANGELOG [Unreleased]) | — | — | — | +| CI-06 | Rust toolchain pin | line plan "External" | SHIPPED for the workspace (1.99 in v3.0.1); the libretro remainder is LR-02 | — | — | — | + +## 12. Documentation and records + +| ID | Item | Source | Verified status / evidence | Size | Blocker | +|---|---|---|---|---|---| +| DOC-01 | Re-sweep `to-dos/DEFERRED-AND-CARRYOVER-FEATURES.md` | the file | Stale in these places:
- R1 is still `[ ]` (shipped v3.0.0; ACC-12)
- §7's "six header/size mismatches unadjudicated" (now refused; MAP-04)
- §7's "174 families"
- §6 R1 framing
- §10 targets that name v2.9.x releases
- §9's mobile-gap note (FDS/NSF/DualSystem shipped v2.9.7) | S | +| DOC-02 | `docs/compatibility.md` stale | `:146-149` | Stale in these places:
- "libretro + wasm + mobile presentation remain deferred" (shipped v2.1.10 / v2.9.7)
- `:214` "139/141" (144/144)
- `:218-227` the audio table ("Phase 4" for VRC6/5B/N163)
- `:324` "The microphone remains deferred" (shipped v2.2.0 per `accuracy-ledger.md`)
- `:341` "MMC5 >8 KiB PRG-RAM ... out of scope" (fixed v2.7.2, `core-disposition.md:35`)
- `:346-349` the Open questions (answered, FE-11) | S | +| DOC-03 | `docs/mappers.md` "Open questions" | `:1009-1015` | Stale (MAP-06) | S | +| DOC-04 | `docs/nesdev-hardware-emulation-checklist.md` | `:68,82,92,95,96,109,120-124` | Still claims AccuracyCoin residuals and "MMC3 sub-test #3 residual". AccuracyCoin is 144/144 and MMC3 is closed. "VRC24 test source unresolved" (`:124`) is **UNVERIFIED** | S | +| DOC-05 | `to-dos/ROADMAP.md` housekeeping | `:496-505` (CI-05); "Open questions blocking planning" (a placeholder) | The Status block is very long, and the forward v3.x/v4 section does not exist yet | S | +| DOC-06 | `docs/libretro/UPSTREAM_SYNC.md` | `:157-162` ("No pull request is open; v3.0.0 submits them") | Stale: #2131 merged, docs#1215 is open | S | +| DOC-07 | `VERSION-PLAN.md` "Planned next" | `:60-67` | Lists v3.x only. v3.0.1 "Mortar" (in progress) is missing | S | +| DOC-08 | Stale code comments | see the list below | Stale in these places:
- `crates/rustynes-android/src/gfx.rs:17` says Bisqwit "remains a follow-up"; it is implemented at `:56-66`
- `crates/rustynes-frontend/src/wasm.rs:19-20` and `ntsc.rs:17` name long-done follow-ups (**UNVERIFIED** for the `wasm-canvas` audio)
- `netplay_ui.rs:241` does not mention that `mesh_net.rs` exists | S | +| DOC-09 | `docs/ios-v1.9.9-readiness.md:100-104` | | FDS/NSF are "a post-v2.0.0 carryover". It is a historical record; add a pointer rather than an edit | S | +| DOC-10 | The GitHub wiki refresh (line plan v2.9.4: "last updated at v2.0") | `to-dos/plans/v2.9.4-to-v3.0.0-line-plan.md` | Whether it was done is **UNVERIFIED** (outside the repo) | S-M | + +## 13. Provenance and licensing + +| ID | Item | Source | Verified status / evidence | Size | Blocker | Breaks | +|---|---|---|---|---|---|---| +| PROV-01 | **T-NTSC-PROVENANCE**: is the Bisqwit-style NTSC WGSL derived from the code published on NESdev? | ROADMAP T-NTSC-PROVENANCE; `crates/rustynes-gfx-shaders/src/lib.rs:311` ("no third-party emulator code is incorporated", a self-certification the guardrails forbid) | OPEN for the maintainer. Settling it needs a comparison against the public wiki code (permitted, since it is documentation) and that code's licence | S-M | maintainer decision | none (attribution only; a relicense only if incompatible) | +| PROV-02 | **Vendored TriCNES source in the working tree** | `NOTICE:117-130` (vendored as an MIT golden oracle at `crates/rustynes-test-harness/golden/tricnes/tricnes-full-src/`, commit f388af0b); `.gitignore:262-270` | Documented and deliberate, but in tension with AGENTS.md's firewall wording ("If you find such source in reach, report that it should be removed; do not read it"). Raised for a maintainer ruling: keep it (MIT, attributed) behind a search exclusion (CI-04), or move it outside the tree. This survey's TODO grep matched lines in it incidentally; they were neither read further nor used | S | maintainer decision | none | +| PROV-03 | NC-17 classifications, and `homebrew_boards.rs` "consulted" | `core-disposition.md:120,138` | SHIPPED/CLASSIFIED (maintainer, 2026-10-04; `docs/originality-and-provenance.md:184-191`). `// Provenance:` headers: 34 files (`rg -l '^// Provenance:' crates`), and `provenance_record_audit.rs` gates them | — | — | — | +| PROV-04 | The Sunsoft 5B softened comment | CHANGELOG [Unreleased] | SHIPPED in v3.0.1 | — | — | — | + +## 14. External and upstream-blocked (collected) + +These are cross-references from the themes above: + +- MOB-04 (UniFFI `&mut [u8]`) +- LR-01 (libretro/docs#1215) +- LR-02 (the buildbot image) +- LR-07 (`rust-libretro-sys`) +- RA-03 (RA allowlisting) +- ACC-05 (`$4030.D1` research) +- ACC-07a and MAP-02 (dumps) + +Two more: + +- **`vendor/egui-winit` removal.** Remove it the day crates.io carries an egui-winit newer than 0.36.2 that compiles for wasm32 (`Cargo.toml:178-198`). Size S. +- **Dependency holds forced upstream.** `getrandom` 0.2/0.3 is held by `piccolo` 0.3.3, and `generic-array` 0.14.7 by `crypto-common` 0.1.7 (CHANGELOG [Unreleased]). + +## 15. MiSTer / hardware (notes only; a separate agent covers the FPGA side) + +- **The v3.x hardware-verification release.** The SuperStation One board session, Strands A-F (`to-dos/plans/v2.9.x-final-audit-and-hardware-plan.md`), covers: + - the device-family question: the SS1's 5CSXFC6D6F31 against the 5CSEBA6U23I7 this build targets + - the SDRAM part number + - MemTest + - the four unmeasured properties + - batteries read back as bytes + - the incumbent A/B and the submission decision (Strand F) + - flipping every "no hardware has run any bitstream" anchor +- **Board-only checks:** + - NR-07, the PLL reset (`rtl-disposition.md:74`) + - AUD-28, no OSD message when the off-die probe fails (DEFERRED; `v2.9.2-full-audit-disposition.md:47`) + - `T-MISTER-DIRECTVIDEO` +- **Post-v3.0.0 RTL breadth:** + - Four Score, paddle, Zapper and keyboard + - DDR3 save states (S6), rewind (S7), cheats (S10) + - FDS (S9), expansion audio, PAL / `T-MISTER-VMODE` + - the S2 slot arbiter + - the remaining ~185 mapper families (S4/S5) + - the DE10-Nano second board +- **Sibling items:** + - `$2006` `v_pipeline` equivalence, left UNSURE by the v3.0.1 sweep (`to-dos/mister/TASKS.md:345-351`) + - a purpose-built PPU decay stimulus (`TASKS.md:184`) + - the MiSTer-devel submission (`TASKS.md:443-469`) + +--- + +## (a) The top 15 by user value + +1. **MOB-01, the mobile device run.** Sixty rows. Every Android/iOS feature since v2.7.4, including FDS/NSF, DualSystem and SOCD on mobile, has never run on a phone. +2. **MOB-02 and MOB-03, distribution.** Provision iOS signing (TestFlight may not actually be uploading), then the Play, F-Droid and App Store listings. +3. **The v3.x hardware verification of the MiSTer core** (section 15). This is the project's stated next headline. +4. **NET-01, native 3-4 player netplay.** The mesh exists and is unwired on desktop and mobile. +5. **NET-04, RA-01 and RA-03, hosted signalling plus the browser RA proxy plus allowlisting.** Together they turn the web build's netplay and achievements from code into features. +6. **ACC-08 and ACC-09, triage the 52 KNOWN_BLANK dumps,** starting with *Chu Liu Xiang*, the Sansuu 1-3 Nen titles and *Gradius II (VC)*. +7. **FE-02, a working "disable sprite limit".** The toggle is already in the UI and does nothing. +8. **ACC-01, PAL/Dendy emphasis R/G swap.** A documented hardware behaviour, wrong today on every PAL game that uses emphasis. +9. **MAP-03, the missing families with real titles.** Mapper 8 (staged dumps already refused), 157, 158, 207, 116 and so on. +10. **PERF-01, recover the 4-5% palette-workload offset** (one A/B away from a cause). +11. **FE-01, the CPU-multiplier overclock.** +12. **ACC-07, Vs. DualSystem real cabinets** (the Wrecking Crew combined-dump investigation needs no new dumps). +13. **CR-03, the full input stream in movies** (Zapper, Vs. coins, FDS disk swaps), so those games can be TASed and replayed faithfully. +14. **MOB-07, iOS parity:** box art, a widget, an external display, and EQ and a cheat DB on both mobile apps. +15. **FE-09, run-ahead and rewind in DualSystem mode,** or MAP-01, BestEffort promotions (honesty, low cost). + +## (b) Items that can only land in a MAJOR (v4.0.0), or that would make one + +Under `VERSION-PLAN.md` a MAJOR is a public-API break, a save-state-format break, or a new deliverable class (ADR 0041). + +**What actually needs a MAJOR.** + +1. **Adding a variant to any exhaustive public enum.** + - T-API-EXTENSIBLE (v3.0.0) made four structs `#[non_exhaustive]`, but most public enums are still exhaustive. By a rough count of `pub enum` against an attribute-adjacent `#[non_exhaustive]`: + - `rustynes-core`: about 7 of 20 are non-exhaustive + - `rustynes-mappers`: 6 of 27 + - `rustynes-apu`: 2 of 7 + - `rustynes-ppu`: 1 of 6 + - `rustynes-cpu`: 1 of 2 + - `rustynes-netplay`: 1 of 14 + - Features above that would add a variant (FE-01 or FE-02 needing an option enum, CR-03 a new input-event kind, new error variants) are API breaks wherever the enum is exhaustive. + - **Candidate v4.0.0 break:** mark the remaining public enums `#[non_exhaustive]` in one go, the enum half of T-API-EXTENSIBLE. This is the only item found that *must* be a MAJOR. +2. **MOB-04, the borrowed-framebuffer bridge API.** It breaks the `rustynes-mobile` UniFFI surface (generated Kotlin/Swift). The bridge is in-repo, so it is a MAJOR only if the bridge counts as public API, which is a maintainer call. +3. **Removing anything deprecated.** None today: `rg '#\[deprecated'` finds nothing outside `vendor/`. + +**Format breaks a MAJOR could batch, though current practice lands them in minors and patches.** + +- CR-03 (`.rnm` format plus input stream; possibly the netplay protocol). +- FE-01 and FE-02 (option carriage in `.rnm` and `config_digest`, which means a netplay protocol bump). +- ACC-03 (phi2 write placement: behaviour, state, goldens). +- ACC-04 (behaviour plus state). + +Practice so far: + +- v2.9.5 raised `PPU_SNAPSHOT_VERSION` in a minor. +- v2.9.9 raised `.rnm` to format 4 in a minor. +- v3.0.1 raises `EMULATION_EPOCH` in a patch. + +The project therefore treats behaviour, state and movie breaks as non-MAJOR in practice, while `VERSION-PLAN.md` still lists "save-state-format breaks" as a MAJOR trigger. This needs reconciling; see decision (c)3. + +**The new-deliverable-class trigger.** The first hardware-verified FPGA core (ADR 0041, kept available by ADR 0043 Decision 2) may itself be v4.0.0 rather than v3.x. + +## (c) Every pending maintainer decision + +1. **PROV-01 T-NTSC-PROVENANCE:** derived or independent, plus attribution and licence. +2. **PROV-02:** keep the vendored TriCNES source in the tree (behind a search exclusion), or move it out. +3. **SemVer policy:** are save-state, movie and epoch breaks MAJOR triggers (`VERSION-PLAN.md` says save-state breaks are) or allowed in minors and patches (as practised at v2.9.5, v2.9.9 and v3.0.1)? Settle it before planning v4.0.0. +4. **Hardware-verified core: v3.x MINOR or v4.0.0 MAJOR** (ADR 0043 Decision 2, decided "when it is planned"). +5. **Strand F:** submit to MiSTer-devel or not, after re-measuring the incumbent (the hardware release). +6. **Distribution timing:** app stores and F-Droid "after the v3.x hardware release" (ADR 0035, line plan), and iOS signing provisioning (MOB-02). +7. **CI-01:** enable the merge queue. +8. **MAP-01:** do non-redistributable local dumps count as Curated evidence for BestEffort promotion (the F3 and MC-ACC precedents)? +9. **MAP-03 and MAP-05:** which missing and long-tail families to take on, under the compatibility.md long-tail policy. +10. **ACC-02:** model the differential phase distortion and inter-pixel artifacts (opt-in?). +11. **ACC-03:** reopen phi2 write placement, against the v2.6.17 reopen conditions. +12. **ACC-04:** attempt the sprite-0 stale-shifter without a motivating game? +13. **ACC-13:** offer NEC rev B MMC3 as a config override? +14. **FE-01 and FE-02:** design the CPU overclock and sprite-limit options, including how they ride in movies and netplay (a format and protocol bump). +15. **FE-09:** lift any of ADR 0032's dual-mode exclusions? +16. **MOB-06 (Android debugger) and MOB-07 (iOS widget, external display, box art, EQ, cheat DB):** which are wanted. +17. **LR-04:** any additional libretro buildbot targets. +18. **NET-04 and RA-01:** host the signalling, TURN and RA proxy (domain, operator, cost). +19. **Retire or fold `to-dos/v1.8.x-on-device-verification.md`** (26 open boxes) into the v2.9.3 run sheet. +20. **Carried from v3.0.1:** + - the palette-offset A/B verdict (PERF-01, a quiet host) + - the final tmp-salvage MOVE (CLAUDE.local "OPEN") + - the agy 403 scope issue (CLAUDE.local, tooling only) + +--- + +## Counts per theme + +Counted as distinct item rows: + +| Theme | Total | OPEN or PARTIAL | MANUAL | BY-DESIGN | SHIPPED or OBSOLETE (the catalogue was stale) | +|---|---|---|---|---|---| +| Accuracy | 14 | 9 | 0 | 3 (ACC-03, 11, 13) | 1 (ACC-12) | +| Mappers | 6 | 4 (MAP-01, 02, 03, 05) | 0 | 0 | 2 (MAP-04; MAP-06 is a doc fix) | +| Platforms / mobile | 9 | 9 | (MOB-01 is device-bound) | 0 | 0 (inside MOB-07, crash reporting and FDS/NSF shipped) | +| Libretro | 7 | 4 | 0 | 1 | 2 (LR-06; LR-07 is UNVERIFIED) | +| Desktop frontend & UX | 11 | 9 | 0 | 1 (FE-09) | 1 (FE-11) | +| Creator / TAS / scripting | 5 | 3 | 0 | 1 | 1 (CR-05 covers 7 shipped entries) | +| Netplay | 5 | 2 | 2 | 1 | 0 | +| RetroAchievements | 3 | 2 | 1 | 0 | 0 | +| Audio / video | 3 | 0 (AV-03 is a cross-reference) | 1 | 0 | 1 (AV-02, UNVERIFIED) | +| Performance | 3 | 1 | 0 | 1 | 0 (PERF-03 is a cross-reference) | +| CI / tooling / quality | 6 | 4 | 0 | 0 | 2 (CI-05, CI-06) | +| Documentation / records | 10 | 10 | 0 | 0 | 0 | +| Provenance / licensing | 4 | 2 | 0 | 0 | 2 | +| External / upstream | 2 new, plus 8 cross-references | 2 | 0 | 0 | 0 | +| MiSTer / hardware | noted only (about 20 items) | — | — | — | — | + +The DEFERRED catalogue's 12 sections were all re-checked: + +- **Confirmed stale:** + - §6b R1 is shipped. + - §7's header mismatches are adjudicated. + - §7's count is 191, not 174. + - §9's preamble is out of date. +- **Confirmed still open:** + - §1: A/V sync; the Multi-Disk Bundler. + - §3: WebRTC and NAT. + - §4: the proxy, the live verify, allowlisting. + - §5 and §10: the manual verifies. + - §6b: the sprite-0 stale-shifter. + - §6c: the CPU overclock and sprite limit. + - §7: zero-dump families; *Chu Liu Xiang* and the broken boots; the long tail. + - §9: the Android debugger; the desktop `HostWarning` migration. + - §11: the merge queue. +- **Already correctly closed:** everything else. diff --git a/to-dos/plans/v3.1-to-v4.0-line-plan.md b/to-dos/plans/v3.1-to-v4.0-line-plan.md index 834f45d2..e2e34b58 100644 --- a/to-dos/plans/v3.1-to-v4.0-line-plan.md +++ b/to-dos/plans/v3.1-to-v4.0-line-plan.md @@ -3,7 +3,11 @@ This is the line after v3.0.0 "Cornerstone" and the v3.0.1 "Mortar" patch. It was drafted on 2026-10-07 from three read-only surveys of both repositories and -the outside ecosystem, about 110 oracle items and 60 sibling items, and shaped +the outside ecosystem, about 110 oracle items and 60 sibling items (kept verbatim +since v3.2.0 in [`research/`](research/README.md): the +[oracle](research/v3.1-line-survey-oracle-backlog.md) and +[sibling](research/v3.1-line-survey-mister-backlog.md) backlogs and the +[ecosystem](research/v3.1-line-survey-ecosystem.md) survey), and shaped by 28 maintainer decisions taken the same day (D1-D28, the table at the end), plus D29, which moved the hardware release to the end of the line. It continues From 3974c230226c431d2ed4ea8d591546eafa7b0890 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 13:45:22 -0400 Subject: [PATCH 072/104] docs(perf): the import's speed claims, re-measured against its own base The imported codex_sec branch's handoff claimed, against the v3.1.0 tag, mapper reads 11-16% faster, MMC3 run-ahead 12.8% and the PPU 3.5%. A v3.1.0 baseline credits the import with everything else v3.2.0 did, so each was re-measured against c922ed40, the tree immediately before the import. ab_check.sh through the new quiet_ab.sh (every start at a load under 1.5), two independent runs per target, interleaved so the two runs of one target are ~15 minutes apart rather than back to back. Reproduced, reading each candidate change against the order-bias control: MMC3 cpu_read -9.22% (ctl -0.24) / -9.31% (ctl -0.38) MMC3 run-ahead -6.67% (ctl -1.19) / -6.03% (ctl -0.74), ~5% net MMC5 cpu_read +4.63% (ctl -0.13) / +4.66% (ctl -0.08) SLOWER FME-7 cpu_read +6.07% (ctl -3.04) / +6.06% (ctl +0.20) SLOWER No change: NROM, MMC1, M34 reads (signs differ or within drift), NROM run-ahead (within drift), the four full-frame workloads (run 1's -3.0% and -2.4% on the nestest pair are order drift: the control moved -3.2% and -2.1%; run 2 has mixed signs), ppu_throughput (p = 0.73, 0.25). The MMC3 gain is attributable: the import cached its PRG and CHR bank offsets, recomputed when a bank register or mode changes and in load_state. The MMC5 and FME-7 slowdowns are NOT attributed: git diff c922ed40 HEAD shows neither cpu_read changed (their diffs are an audio helper and the newly required ppu_peek) and the bench file is unchanged. Code layout of the trait-object dispatch is a candidate, not shown; no disassembly or alignment experiment was run. There is no change on the measured path to revert, so it is ticketed (T-PERF-MAPPER-READ-DRIFT, release-cut commit), not reverted. Neither board is in the frame benches. The APU section's evidence line pointed at session scratch, which /tmp ageing deletes; its four logs now sit in the gitignored salvaged/evidence/v3.2.0-perf/ with these runs, and the line cites the full-suite result (3,659 passed, 0 failed). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- docs/performance.md | 55 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index d7fb1606..14d5a180 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1186,8 +1186,59 @@ and the filter split. Rows 3 and 4 are two independent runs with the same sign on every workload and drift below the effect: `active_frame` -1.9% / -2.7%, `active_frame_with_external` -2.9% / -6.1%, `silent_frame` unchanged. Which of the two kept changes earns which part was not separated. Byte identity: the -APU suite (174 tests) passes; the full suite is re-run at the cut. Logs are in -the gitignored session scratch, not committed. +APU suite (174 tests) passes; the full suite is re-run at the cut (3,659 passed, 0 +failed). Logs: gitignored `salvaged/evidence/v3.2.0-perf/ab-apu-*.log`. + +### v3.2.0 import — the rest of the branch's speed-ups, measured against the tree it joined + +The imported branch's handoff claimed, against the **v3.1.0 tag**, mapper +reads 11-16% faster, MMC3 run-ahead 12.8% and the PPU 3.5%. A v3.1.0 baseline +credits the import with everything else v3.2.0 did, so each was re-measured +against **`c922ed40`, the tree immediately before the import**, with the +working tree at `00c355a2` as the candidate (its uncommitted changes were +documentation, scripts, the fuzz crate and version strings, none in a benched +crate's code). +`scripts/perf/ab_check.sh`, run through `scripts/perf/quiet_ab.sh` (each start +at a one-minute load under 1.5), two independent runs per target, taken +interleaved rather than back to back. Each cell is the candidate's change with +the order-bias control (the reference against itself) in parentheses. + +| workload | run 1 | run 2 | verdict | +| --- | --- | --- | --- | +| `mapper_dispatch_cpu_read/MMC3 (4)` | -9.22% (-0.24) | -9.31% (-0.38) | **faster, reproduced** | +| `mapper_dispatch_cpu_read/MMC5 (5)` | +4.63% (-0.13) | +4.66% (-0.08) | **slower, reproduced** | +| `mapper_dispatch_cpu_read/FME-7 (69)` | +6.07% (-3.04) | +6.06% (+0.20) | **slower** (run 2 clean; run 1's control drifted) | +| `mapper_dispatch_cpu_read/NROM (0)` | -2.91% (+0.61) | +0.18%, p = 0.18 (+0.00) | no change (signs differ) | +| `mapper_dispatch_cpu_read/MMC1 (1)` | -3.59% (-0.65) | -0.39% (-0.31) | no change (run 2 within drift) | +| `mapper_dispatch_cpu_read/M34 (34)` | -0.01%, p = 0.93 | +0.01%, p = 0.93 | no change | +| `nes_runahead_budget_mmc3` | -6.67% (-1.19) | -6.03% (-0.74) | **faster, about 5% net, reproduced** | +| `nes_runahead_budget_flowing_palette` | -1.05% (-0.93) | -1.03% (-0.81) | no change (within drift) | +| `nes_run_frame_nestest` | -3.02% (-3.16) | -0.23%, p = 0.12 (-0.37) | no change (run 1 is drift) | +| `nes_run_frame_flowing_palette` | -0.42% (+0.18) | -0.83% (-0.29) | at most ~0.5%; too small to call | +| `nes_run_frame_nestest_fast` | -2.38% (-2.14) | +0.84% (-0.22) | no change (signs differ) | +| `nes_run_frame_flowing_palette_fast` | -0.75% (+0.04) | -0.20%, p = 0.12 (+0.23) | no change | +| `ppu_throughput` | -0.12%, p = 0.73 (-0.86) | +0.42%, p = 0.25 (-0.11) | no change | + +**What holds.** The MMC3's cached bank offsets (`prg_offsets` / `chr_offsets`, +recomputed when a bank register or mode actually changes, and in +`load_state`) make its read about +9% faster, and carry through to run-ahead on an MMC3 game at about 5%. Nothing +else the branch claimed is measurable against this tree: the PPU, the frame +loop and the NROM and MMC1 reads do not move outside their controls. The +frame workloads are NROM, so the MMC3 gain is not visible there by +construction. + +**What got slower, unexplained.** MMC5 and FME-7 reads are 4.7% and 6.1% +slower, reproduced, yet `git diff c922ed40 HEAD` shows neither +`Mmc5::cpu_read` nor `Fme7::cpu_read` changed: their diffs are an audio +helper and the new required `ppu_peek`. The bench file is unchanged too. Code +layout of the trait-object dispatch is a candidate and is **not shown**: no +disassembly or alignment experiment was run. Neither reaches the frame +benches (both are absent from them) and the effect on a real MMC5 or FME-7 +frame was not measured. Recorded as `T-PERF-MAPPER-READ-DRIFT` rather than +reverted, because there is no change on the measured path to revert. + +Logs: gitignored `salvaged/evidence/v3.2.0-perf/queue/logs/`. ### v2.9.9 campaign — the leads, measured (one adopted) From 975826fc1d4ae525481a67f6c647d2b769c40344 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 13:46:25 -0400 Subject: [PATCH 073/104] chore(release): v3.2.0 "Confluence" The cut. CHANGELOG [Unreleased] becomes [3.2.0] - 2026-10-14 - "Confluence", and scripts/release-automation/bump_release.py --apply moved every release anchor to it: the workspace and cosim versions (all internal dependencies; the lockfiles move by version only), the Android versionCode 30100 -> 30200, the iOS project, the libretro .info display_version, and the "current release" lead in README, OVERVIEW, ROADMAP, SECURITY, SUPPORT, ARCHITECTURE, AGENTS, VERSION-PLAN, docs/STATUS.md and to-dos/ROADMAP.md, each demoting v3.1.0 "Bellwether" to "Built on" rather than overwriting it. The dry run refused once: the to-dos/ROADMAP.md release chain ended "v3.1.0 ..., the current release", so the chain gained a written v3.2.0 entry before --apply. EMULATION_EPOCH was already 4 on this branch (the mapper 290, 24C02, VRC4 IRQ, VRC2 and Fire Hawk fixes); epoch_fingerprint.tsv's last_release_epoch moves 3 -> 4 by hand at the cut, as its header requires, so output may move again only after the next epoch bump. .github/release-notes/v3.2.0.md is written from both repositories' branch logs (pre-release.sh lists 25 sibling commits; the notes cover the boards, input devices, sprite limit, palette, pin move, ladder and bitstreams), one line per paragraph. Its figures were filled from measurements, not estimates: the test totals (3,659 passed, 0 failed, 16 ignored), the ladders (on-die 257 / 0 / 3, off-die 258 / 0 / 5, each one frozen-worktree run at sibling 38644c4 with nothing skipped), the bitstream md5s, and the import's speed results from the previous commit. Two counts were wrong in earlier drafts and were corrected before this commit: commercial-frame gates cover thirteen of the sixteen new boards, not twelve (SUROM's Dragon Warrior III runs off-die), and no AccuracyCoin stream is "new" against v3.1.0 (their skips were a v3.2.0-branch path bug). CHANGELOG [3.2.0] also gains: a Records section (the fuzz target, the promoted tools, the surveys), the ladder totals and the thirteen-board correction in the MiSTer section, and the import's measured speed results in Performance. VERSION-PLAN gains the v3.2.0 row. to-dos/ROADMAP.md mints T-SURVEY-UNTICKETED and T-PERF-MAPPER-READ-DRIFT. Verified at this tree: the eight release audits (release_anchor, release_notes_render, release_state_prose, contribution_checklist, provenance_record, libretro_info, cosim_manifest, epoch_fingerprint), 58 tests, 0 failed; pre-release.sh "nothing definitively wrong"; markdownlint on every changed document. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 60 +++++++++++++++++++ AGENTS.md | 4 +- ARCHITECTURE.md | 2 +- CHANGELOG.md | 43 ++++++++++++- Cargo.lock | 38 ++++++------ Cargo.toml | 2 +- OVERVIEW.md | 6 +- README.md | 4 +- ROADMAP.md | 4 +- SECURITY.md | 2 +- SUPPORT.md | 2 +- VERSION-PLAN.md | 5 +- android/app/build.gradle.kts | 4 +- crates/rustynes-cosim/Cargo.lock | 12 ++-- crates/rustynes-cosim/Cargo.toml | 2 +- .../rustynes-libretro/rustynes_libretro.info | 2 +- .../golden/epoch_fingerprint.tsv | 2 +- docs/STATUS.md | 2 +- ios/project.yml | 2 +- to-dos/ROADMAP.md | 6 +- 20 files changed, 153 insertions(+), 51 deletions(-) create mode 100644 .github/release-notes/v3.2.0.md diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md new file mode 100644 index 00000000..a4d445f6 --- /dev/null +++ b/.github/release-notes/v3.2.0.md @@ -0,0 +1,60 @@ +# RustyNES v3.2.0 — "Confluence" + +Every v3.2.0 branch, both repositories, brought into one release. It adds 17 mapper families (191 to 208), among them the Bandai Datach with its barcode reader, and five more families are Curated on local-dump evidence. Three emulation defects are fixed: the VRC4's IRQ mode was read inverted, VRC2 mirroring had settings the chip does not have, and *Fire Hawk* lost its single-screen mirroring on an iNES 1.0 header. An AI-made remediation branch was audited change by change before anything was kept: its byte-identical speed-ups are kept, and measured against the tree it joined: the APU mixes 2-6% faster and an MMC3 read about 9% faster, while its larger claims did not hold and two cartridge reads got slower for a reason not yet found; a 47% audio slowdown it introduced was found and removed; four of the five features it broke are fixed and kept, and Android audio is back on its blocking write, with the non-blocking version a ticket. Netplay moves to protocol 8, libretro save states shrink from 260 KB to about 16 KB, and new save files are owner-only. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line and custom palettes. **Movies and netplay from v3.1.0 are refused.** + +## Breaking changes + +- **Movies and netplay from v3.1.0 are refused**: `EMULATION_EPOCH` rises from 3 to 4, because the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes change what the bus sees. Save states are not affected by the epoch. +- **Netplay protocol 8 (`"RNE8"`)**: lost inputs are resent as one datagram per run of frames. A v3.1.x peer is refused as another emulator version. + +## Fixed + +- **The VRC4's IRQ runs in the mode the game asks for** (mappers 21, 23 and 25): bit 2 of the IRQ control register was read inverted, so a raster split got a counter about 114 times too fast. +- **VRC2 mirroring has two settings, not four**, and mappers 23 and 25 with NES 2.0 submapper 3 are built as the VRC2. +- ***Fire Hawk* gets its single-screen mirroring from an iNES 1.0 header** (mapper 71, submapper 0, at the first `$9000` write). +- **Mapper 290 (*Asder 20-in-1*) boots**, and **a 24C02 EEPROM keeps its word address across a START** (mappers 16 and 157). +- **FDS disk games keep their saves on Android and iOS**, and **the Android screen stays on during play**. + +## Added + +- **Mapper 157, the Bandai Datach Joint ROM System**, with a barcode reader for the games that read cards. +- **16 more families**: the MMC3-derived 114, 116, 165, 182, 197 and 215; FFE 6, 8 and 17; Tengen 158; TXC 172 and 173; Taito 207; the 235 multicarts and Waixing 252 (all Curated), and the 230 multicart (BestEffort). Mappers 47, 121, 154, 191 and 243 move to Curated. The split is now 51 Core, 130 Curated and 27 BestEffort: 181 of 208 families are accuracy-gated. +- **Famicom-only carts boot as a Famicom in the coverage sweep**, so ten dumps listed as blank now draw. + +## Changed + +- **Libretro save states are about 16 KB instead of 260 KB**: the framebuffer is no longer stored, and a full-size state still loads. +- **New save files are owner-only on Linux and macOS** (`0o600`). +- **The APU mixes 2-6% faster with expansion audio and 2-3% faster without**, byte-identical, in two independent A/B runs. +- **An MMC3 cartridge read is about 9% faster, and run-ahead on an MMC3 game about 5%**, from cached bank offsets; byte-identical, two independent runs. Whole-frame time and the PPU do not change measurably. MMC5 and Sunsoft FME-7 reads measure 4.7% and 6% slower although neither read function changed; the cause is not established (`docs/performance.md`, "v3.2.0 import"). + +## The AI remediation branch, audited + +An unpushed agent branch arrived with 61 audit findings already applied. Nothing was merged as is. Every change was read against the code it replaced, and each defect found has a test that fails without the fix. Kept: 20 as written, 14 corrected. Reverted: 4, the audio ring write behind the 47% slowdown, an MMC5 IRQ change under which the IRQ stopped firing in real play, and two unmeasured libretro rewrites; an FDS audio formula change inside a kept finding was reverted too, because it moved every FDS game's audio. Rejected: 10, including six of its seven FPGA findings, which did not hold when re-derived in simulation and synthesis. The ledger is `docs/audits/v3.2.0-round2-audit-disposition.md`; the report's "certified" and "100% bit-accurate" claims are recorded as unverified, and two of them were tested and are false. + +## The MiSTer core + +- **Release candidate, not hardware-verified.** No hardware has run either bitstream. +- **Sixteen more boards**: UN1ROM, Nichibutsu UNROM, CPROM, J87, CNROM 185, GxROM, Jaleco JF-11/14, Color Dreams, NINA-03/06, Camerica 71 (including *Fire Hawk*) and 232, mapper 34, Namco 108, TxSROM, TQROM and MMC1 SUROM (off-die). Each is gated against the emulator, and thirteen against a commercial game's frame (SUROM's off-die only). +- **The Four Score, the Arkanoid controller and Game Genie cheats**, gated against goldens exported with the same device. +- **More than eight sprites per line** (on-die build) and **custom `.pal` palettes** with the emulator's emphasis model. +- **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems reproduce byte for byte. +- **The co-simulation ladder**: 257 passed, 0 failed, 3 expected failures on-die and 258 / 0 / 5 off-die (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each one run of a frozen tree, nothing skipped. The new expected failures are named and graded both ways: two commercial frames still differ from the emulator's, by one pixel (a sprite at X=255 on its last row) and by three scanlines of a mid-frame raster change, neither yet attributed; off-die, two more are a CHR-RAM read-back defect that predates this release (`T-MISTER-OFFDIE-CHRRAM`). +- **New bitstreams at fitter seed 2**, chosen from eight seeds swept on build date 261014 (seed 1, v3.1.0's, no longer closes on-die). Each compiled twice to the same bytes: + - on-die `RustyNES_MiSTer-v3.2.0.rbf`, md5 `5e9edb142777cdd9346f0bd3d86f3590` (+0.091 ns setup, +0.105 ns hold); + - off-die `RustyNES_MiSTer-v3.2.0-offdie.rbf`, md5 `043fc338fb2e52da21acd8adc91d6678` (+0.040 / +0.060 ns; the SDRAM read +0.447 / +1.184 ns, assuming zero board delay), thinner than three other seeds off-die, recorded rather than bent. + +## Verification + +- `--features test-roms,commercial-roms`: 3,659 passed, 0 failed, 16 ignored, across 176 test binaries. +- The local commercial suites: `external_real_games` 60/0, `external_extended` 169/0, `external_coverage` 6/0 over every staged dump, 25 of them baselined new in this release. +- Every clippy, rustdoc, `no_std` and wasm invocation CI runs (23), clean. +- AccuracyCoin 146/146, nestest 0-diff, the epoch fingerprint at epoch 4, now recorded as the released epoch. + +## Install + +- Download the pre-built binaries for Linux, macOS, and Windows below. +- The MiSTer core bitstreams are attached below, as a release candidate, not hardware-verified. The on-die build is `RustyNES_MiSTer-v3.2.0.rbf`, also attached under its datecoded name, and the off-die build is `RustyNES_MiSTer-v3.2.0-offdie.rbf`. +- The WebAssembly build is live at [doublegate.github.io/RustyNES](https://doublegate.github.io/RustyNES/). +- The RetroArch core is in RetroArch's Online Updater on the platforms the libretro buildbot publishes to. +- Licensed under GPL-3.0-or-later. diff --git a/AGENTS.md b/AGENTS.md index 2e9acbb5..231def8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,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: v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). **No hardware has run any MiSTer bitstream**: hardware verification moves to a later v3.x release (ADR 0043 supersedes [ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)'s hardware-verified v3.0.0), and the SDRAM pin constraints stay provisional until the SuperStation One's memory is read. Suite counts, mapper matrix and per-release detail live in `docs/STATUS.md`, `CHANGELOG.md` and the GitHub releases; they are deliberately not duplicated here. The earlier release-by-release narrative and other condensed paragraphs are archived verbatim in `docs/history/AGENTS-archive.md`. +**Current release: v3.2.0 "Confluence"** (2026-10-14) — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). **No hardware has run any MiSTer bitstream**: hardware verification moves to a later v3.x release (ADR 0043 supersedes [ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)'s hardware-verified v3.0.0), and the SDRAM pin constraints stay provisional until the SuperStation One's memory is read. Suite counts, mapper matrix and per-release detail live in `docs/STATUS.md`, `CHANGELOG.md` and the GitHub releases; they are deliberately not duplicated here. The earlier release-by-release narrative and other condensed paragraphs are archived verbatim in `docs/history/AGENTS-archive.md`. - **Native Android app** — the **v1.8.0 → v1.8.9 "Android"** train (`crates/rustynes-mobile` UniFFI bridge + `crates/rustynes-android` JNI/NDK host + a Jetpack Compose app, ADR 0024): full on-device emulation, multi-touch + P1–P4 hardware controllers, wgpu `SurfaceView` rendering + the shared WGSL shader stack, save-states / battery SRAM, Lua, RetroAchievements, direct-IP + CGNAT/TURN room-code netplay, a box-art ROM library, and platform polish (adaptive / foldable / TV, Material You, capture / PiP / home-screen widget). Distributed as **GitHub-Releases sideload**; a free store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0025's v2.3.0 date; the `foss`/`play` flavour split remains). - **Native iOS / iPadOS app** — the **v1.9.0 → v1.9.9 "iOS" TestFlight train** (`crates/rustynes-ios` Metal + CoreAudio shim reusing `rustynes-mobile` verbatim → UniFFI-generated Swift, ADR 0026): a native SwiftUI shell over wgpu→Metal, multi-touch + GameController, the shader stack, TAS / HD-pack / palettes / per-game DB, Lua + RetroAchievements, LAN + room-code netplay, CloudKit save-state sync, accessibility + EN/ES i18n + ReplayKit + Game Center, and the v1.9.9 creator tools (Cheats, a FOSS-gated read-only debugger, a touch TAStudio piano-roll, foreign movie import, a host audio-depth DSP). Ships to **TestFlight**; a free App Store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0027's v2.3.0 date). Mobile ROM loading was iNES / NES 2.0 only until v2.9.7, which adds FDS (host-supplied BIOS), NSF and the Vs. DualSystem cabinet to the bridge (the Swift half uncompiled; run-sheet rows T1-T12). Readiness record: `docs/ios-v1.9.9-readiness.md`. @@ -236,7 +236,7 @@ that is a reason to add a tenth — not a reason to grow this section back. - `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 **v3.1.0 "Bellwether"** (2026-10-08). **Never claim any version *later* than v3.1.0 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and broke byte-identity and save-state compatibility, by design (v2.9.8 later broke save, movie and API compatibility again, ahead of v3.0.0). The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not. +- 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 **v3.2.0 "Confluence"** (2026-10-14). **Never claim any version *later* than v3.2.0 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and broke byte-identity and save-state compatibility, by design (v2.9.8 later broke save, movie and API compatibility again, ahead of v3.0.0). The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not. - **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.49.1`). The pin was v0.39.0 until the v2.6.3 dependency refresh, held because the newer local binary reported rules the pin lacked — chiefly **MD060** (`table-column-style`), which was therefore NOT gated. That is now measured and resolved: MD060's inferred default reads this corpus as style `compact` and reports **1,936 findings across 122 files** and nothing else, so `.markdownlint.json` pins `MD060` to the style actually in use (`leading_and_trailing`), which measures **zero** and rewrites no document. It IS a gate now. Still verify with `pre-commit run markdownlint --all-files` rather than the bare binary — the pin and the local build can drift apart again. `.markdownlint.json` also 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/ARCHITECTURE.md b/ARCHITECTURE.md index 7a9984ce..15f7eeba 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ **Document Version:** 2.1.0 **Last Updated:** 2026-10-07 -**Applies to:** RustyNES v3.1.0 (the scheduling model is v2.0.0 "Timebase" onward) +**Applies to:** RustyNES v3.2.0 (the scheduling model is v2.0.0 "Timebase" onward) This document fixes the high-level architecture of RustyNES. The per-subsystem specs under `docs/` (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `scheduler.md`) take these decisions as given and elaborate one chip each. After reading this you should know the workspace shape, the scheduling model, the public boundary, and the load-bearing invariants. The canonical, always-current architecture spec is [`docs/architecture.md`](docs/architecture.md); this file is the top-level companion. diff --git a/CHANGELOG.md b/CHANGELOG.md index e38aa881..dac930c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ cycle-accurate core later replaced. ## [Unreleased] +## [3.2.0] - 2026-10-14 - "Confluence" (every v3.2.0 branch in both repositories brought into one release: 17 mapper families, the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core) + ### Breaking - **Movies and netplay from v3.1.0 are refused.** `EMULATION_EPOCH` rises @@ -97,6 +99,25 @@ cycle-accurate core later replaced. checked against services.gradle.org) and the **Compose compiler plugin 2.4.21** (#603) for the Android app. +### Records + +- **A ninth fuzz target, `movie_import`**, covers the five foreign-movie + importers (`.fm2`, `.bk2`, `.fcm`, `.fmv`, `.vmv`), which no target reached: + `movie` fuzzes only the `.rnm` decoder. Clean over 2,000,000 inputs when + added; CI's `fuzz` job now expects nine targets. +- **Tools that existed only in untracked scratch are in the repositories.** + The release ceremony's local gate runner and squash-merge containment check + (`scripts/release-automation/run_gates.sh`, `branch_contained.py`), the + quiet-host A/B queue (`scripts/perf/quiet_ab.sh`), and in the MiSTer + repository the double-compile behind every "byte-identical across two clean + compiles" claim (`scripts/double-compile.sh`) and three gate-writing tools + (`tb/mutate_gate.py`, `tb/export_golden.sh`, `tb/sweep_frames.sh`). Each was + tested before it was committed: the containment check against a synthetic + squash merge, the mutation tool by a real catch and a no-op control. +- **The three surveys the v3.1-to-v4.0 line plan was drafted from** are kept + verbatim in `to-dos/plans/research/`. 25 of the oracle survey's items appear + in no plan or ticket, and `T-SURVEY-UNTICKETED` triages them. + ### Performance - **The APU mixes about 2-6% faster when a cartridge adds audio, and 2-3% @@ -104,6 +125,17 @@ cycle-accurate core later replaced. (`docs/performance.md`, "v3.2.0 APU"). The change came with an AI-made remediation branch that also made the expansion-audio path 47% SLOWER; the cause, a reshaped ring write, was bisected and left out. +- **The rest of that branch's speed-ups, measured against the tree before it** + (two independent runs each, `docs/performance.md`, "v3.2.0 import"): an + MMC3 cartridge read is about 9% faster (its bank offsets are now cached), + and run-ahead on an MMC3 game about 5% faster with it. Whole-frame time, + PPU throughput and run-ahead on an NROM game do not change measurably. The + branch's own figures (PPU 3.5%, mapper reads 11-16%, MMC3 run-ahead + 12.8%) were taken against the v3.1.0 tag, and do not hold against the + tree it was imported into. Two cartridge reads got + slower, MMC5 by about 4.7% and Sunsoft FME-7 by about 6%, although neither + read function changed; the cause is not established + (`T-PERF-MAPPER-READ-DRIFT`). ### The MiSTer core (`RustyNES_MiSTer`) @@ -118,8 +150,9 @@ cycle-accurate core later replaced. NINA-03/06 (79), Camerica 71 (including *Fire Hawk*) and 232, mapper 34 (BNROM and NINA-001), Namco 108 (206), TxSROM (118), TQROM (119) and MMC1 SUROM (512 KiB, off-die). The NES 2.0 submapper now reaches the cartridge. - Each board is gated against the emulator, and for twelve of them a - commercial game's frame is compared too (`docs/rung7-mappers.md`). + Each board is gated against the emulator, and for thirteen of them a + commercial game's frame is compared too (SUROM's off-die only; + `docs/rung7-mappers.md`). - **Three input devices**: the Four Score, the Arkanoid (Vaus) controller and Game Genie cheats, gated against goldens exported with the same device. - **The custom palette uses 1,004 fewer combinational ALUTs** (2,390 -> @@ -128,6 +161,12 @@ cycle-accurate core later replaced. registers) and is deferred, six did not hold. - **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems reproduce byte for byte from it. +- **The co-simulation ladder: 257 passed, 0 failed, 3 expected failures + on-die, 258 / 0 / 5 off-die** (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each + one frozen-worktree run with nothing skipped. Two commercial frames still + differ from the emulator's and are expected failures, unattributed: one + pixel of *Mississippi Satsujin Jiken* and three scanlines of *Micro + Machines*. - **Known issue (off-die build):** a `$2007` read of CHR-RAM soon after a write returns `$00` in simulation. It predates this release, is an expected failure in the off-die ladder, and the on-die build is right. diff --git a/Cargo.lock b/Cargo.lock index 0cb0294e..731ffe4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "rustynes-android" -version = "3.1.0" +version = "3.2.0" dependencies = [ "android-activity", "android_logger", @@ -4308,7 +4308,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "rustynes-cheevos" -version = "3.1.0" +version = "3.2.0" dependencies = [ "cc", "libc", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4347,7 +4347,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4358,7 +4358,7 @@ dependencies = [ [[package]] name = "rustynes-frontend" -version = "3.1.0" +version = "3.2.0" dependencies = [ "anstyle", "arboard", @@ -4417,18 +4417,18 @@ dependencies = [ [[package]] name = "rustynes-gamedb" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-gfx-shaders" -version = "3.1.0" +version = "3.2.0" [[package]] name = "rustynes-hdpack" -version = "3.1.0" +version = "3.2.0" dependencies = [ "lewton", "png", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "rustynes-ios" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bytemuck", "cpal", @@ -4453,7 +4453,7 @@ dependencies = [ [[package]] name = "rustynes-libretro" -version = "3.1.0" +version = "3.2.0" dependencies = [ "libc", "rust-libretro", @@ -4463,7 +4463,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4475,7 +4475,7 @@ dependencies = [ [[package]] name = "rustynes-mobile" -version = "3.1.0" +version = "3.2.0" dependencies = [ "getrandom 0.2.17", "rustynes-core", @@ -4492,7 +4492,7 @@ dependencies = [ [[package]] name = "rustynes-netplay" -version = "3.1.0" +version = "3.2.0" dependencies = [ "futures-util", "js-sys", @@ -4508,7 +4508,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags 2.13.2", "criterion", @@ -4520,21 +4520,21 @@ dependencies = [ [[package]] name = "rustynes-probe" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-core", ] [[package]] name = "rustynes-ra" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-cheevos", ] [[package]] name = "rustynes-script" -version = "3.1.0" +version = "3.2.0" dependencies = [ "mlua", "piccolo", @@ -4545,7 +4545,7 @@ dependencies = [ [[package]] name = "rustynes-test-harness" -version = "3.1.0" +version = "3.2.0" dependencies = [ "insta", "png", diff --git a/Cargo.toml b/Cargo.toml index 00e10ebf..1d6bafec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,7 @@ default-members = ["crates/rustynes-libretro"] # `release-auto.yml` reads the `## [X.Y.Z]` line for BOTH the release body # fallback and the title codename — so the date and quoted codename are load- # bearing, not decoration. -version = "3.1.0" +version = "3.2.0" edition = "2024" # The toolchain is 1.99 (rust-toolchain.toml), and every crate inherits this # floor, the libretro path included: the libretro buildbot follows the same pin diff --git a/OVERVIEW.md b/OVERVIEW.md index e9a3cddf..faf1f214 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -2,7 +2,7 @@ **Document Version:** 2.1.0 **Last Updated:** 2026-10-06 -**Applies to:** RustyNES v3.1.0 +**Applies to:** RustyNES v3.2.0 --- @@ -22,9 +22,9 @@ RustyNES is the **definitive NES emulator for the modern era** — combining cycle-perfect accuracy with a complete contemporary feature set and the safety guarantees of Rust. It is more than an emulator: it is a platform for NES preservation, competitive online play, tool-assisted speedrunning, and homebrew development. -As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the first designated breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** — the access does not increment, it pulses the load already there: the CHR-during-rendering gate closes at 61,440 of 61,440 pixels, and **no hardware has run any bitstream** — built on **v2.6.22 "Rigging"**, the instruments for the board, built before the board: AccuracyCoin reads back from hardware as bytes rather than as a photograph, the catalog turns out to carry 149 rows and 144 results so the headline no longer depends on when the run was sampled (at the same 144/144), and a `rom_sha256` recorded in every golden manifest and compared to nothing is now rung 0 — built on **v2.6.21 "Steward"**, which brought battery saves, the CHR-during-rendering gate RED at 32,861 of 61,440 pixels, and the deploy loop the runbook prescribed and **v2.6.20 "Telltale"**, itself on **v2.6.19 "Accession"** and **v2.6.18 "Errata"**, built on **v2.6.17 "Terminus"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn. +As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the first designated breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** — the access does not increment, it pulses the load already there: the CHR-during-rendering gate closes at 61,440 of 61,440 pixels, and **no hardware has run any bitstream** — built on **v2.6.22 "Rigging"**, the instruments for the board, built before the board: AccuracyCoin reads back from hardware as bytes rather than as a photograph, the catalog turns out to carry 149 rows and 144 results so the headline no longer depends on when the run was sampled (at the same 144/144), and a `rom_sha256` recorded in every golden manifest and compared to nothing is now rung 0 — built on **v2.6.21 "Steward"**, which brought battery saves, the CHR-during-rendering gate RED at 32,861 of 61,440 pixels, and the deploy loop the runbook prescribed and **v2.6.20 "Telltale"**, itself on **v2.6.19 "Accession"** and **v2.6.18 "Errata"**, built on **v2.6.17 "Terminus"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn. -> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v3.1.0**. +> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v3.2.0**. --- diff --git a/README.md b/README.md index 24763636..93108193 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Build Status License: GPL-3.0-or-later Version Rust: 1.99
+ Build Status License: GPL-3.0-or-later Version Rust: 1.99
AccuracyCoin nestest Mapper families Try in browser
Platform

@@ -357,7 +357,7 @@ detailed in [`docs/architecture.md`](docs/architecture.md) and ## Current release -RustyNES's current release is **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. +RustyNES's current release is **v3.2.0 "Confluence"** (2026-10-14) — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. **v3.0.0 is the API major** ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). It gathers every breaking change since v2.x, most of them made early in v2.9.8 and diff --git a/ROADMAP.md b/ROADMAP.md index 781b361d..4a5f0d42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,13 +2,13 @@ **Document Version:** 2.0.4 **Last Updated:** 2026-10-06 -**Project Status:** v3.1.0 "Bellwether" released — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0) and **v3.0.0 "Cornerstone"** (the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core) and **v2.9.9 "Ballast"** (the release candidate for v3.0.0: all four audit scopes re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it with the release-candidate bitstream pair; the tenth release of the v2.9.x line and the sixth of the line to v3.0.0 (ADR 0043, amended)) and **v2.9.8 "Vanguard"** (v3.0.0's breaking changes landed early) and **v2.9.7 "Tandem"** (the desktop's features on the web and on phones). **No hardware has run any bitstream**; the mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). +**Project Status:** v3.2.0 "Confluence" released — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it) and **v3.0.1 "Mortar"** (a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0) and **v3.0.0 "Cornerstone"** (the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core) and **v2.9.9 "Ballast"** (the release candidate for v3.0.0: all four audit scopes re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it with the release-candidate bitstream pair; the tenth release of the v2.9.x line and the sixth of the line to v3.0.0 (ADR 0043, amended)) and **v2.9.8 "Vanguard"** (v3.0.0's breaking changes landed early) and **v2.9.7 "Tandem"** (the desktop's features on the web and on phones). **No hardware has run any bitstream**; the mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). --- ## Where we are -RustyNES is well past v1.0.0. The current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** +RustyNES is well past v1.0.0. The current release is **v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** **This root ROADMAP is a historical snapshot of the v1.0.0 cut.** For the authoritative, current forward roadmap see **[`to-dos/ROADMAP.md`](to-dos/ROADMAP.md)**; for the authoritative current-state pass counts and platform matrix see **[`docs/STATUS.md`](docs/STATUS.md)**; for the full per-release history see **[`CHANGELOG.md`](CHANGELOG.md)**. Many of the "post-1.0 directions" listed further down (mobile, Lua scripting, TAS editor, Vs. DualSystem, HD packs, hosted netplay) have since shipped — the tables below record what was **done at v1.0.0**, not the current feature set. diff --git a/SECURITY.md b/SECURITY.md index 66528b90..48b8308b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Supported Versions -The current release is **v3.1.0 "Bellwether"**. Built on **v3.0.1 "Mortar"** and **v3.0.0 "Cornerstone"** and **v2.9.9 "Ballast"** and **v2.9.8 "Vanguard"** and **v2.9.7 "Tandem"** and **v2.9.6 "Roster"** and **v2.9.5 "Caliper"** and **v2.9.4 "Plumb"** and **v2.9.3 "Handset"** and **v2.9.2 "Candidate"** and **v2.9.1 "Hone"** and **v2.9.0 "Survey"** and **v2.8.4 "Tether"** and **v2.8.3 "Rivet"** and **v2.8.2 "Solder"** and **v2.8.1 "Gasket"** and **v2.8.0 "Bulkhead"** and **v2.7.6 "Recount"** and **v2.7.5 "Tally"** and **v2.7.4 "Pocket"** and **v2.7.3 "Hearth"** and **v2.7.2 "Bankroll"** and **v2.7.1 "Keepsake"** and **v2.7.0 "Palisade"** and **v2.6.23 "Pulse"** and **v2.6.22 "Rigging"** and **v2.6.21 "Steward"** and **v2.6.20 "Telltale"** and **v2.6.19 "Accession"** and **v2.6.18 "Errata"** and **v2.6.17 "Terminus"** and **v2.6.16 "Interlock"** and **v2.6.15 "Warrant"** and **v2.6.14 "Docket"** and **v2.6.13 "Slack"** and **v2.6.12 "Groundwork"** and **v2.6.11 "Exposure"** and **v2.6.10 "Inference"** and **v2.6.9 "Abeyance"** and **v2.6.8 "Arrears"** and **v2.6.7 "Detent"** and **v2.6.6 "Chassis"** and **v2.6.5 "Muster"** and **v2.6.4 "Rubric"** and **v2.6.3 "Mainspring"** and **v2.6.2 "Witness"** and **v2.6.1 "Interleave"** and **v2.6.0 "Assay"** and **v2.5.9 "Overture"** and **v2.5.8 "Blanking"** and **v2.5.7 "Collimation"** and **v2.5.6 "Vestige"** and **v2.5.5 "Raster"** and **v2.5.4 "Escapement"** and **v2.5.3 "Hysteresis"** and **v2.5.2 "Dormant"** and **v2.5.1 "Retrace"** and **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a +The current release is **v3.2.0 "Confluence"**. Built on **v3.1.0 "Bellwether"** and **v3.0.1 "Mortar"** and **v3.0.0 "Cornerstone"** and **v2.9.9 "Ballast"** and **v2.9.8 "Vanguard"** and **v2.9.7 "Tandem"** and **v2.9.6 "Roster"** and **v2.9.5 "Caliper"** and **v2.9.4 "Plumb"** and **v2.9.3 "Handset"** and **v2.9.2 "Candidate"** and **v2.9.1 "Hone"** and **v2.9.0 "Survey"** and **v2.8.4 "Tether"** and **v2.8.3 "Rivet"** and **v2.8.2 "Solder"** and **v2.8.1 "Gasket"** and **v2.8.0 "Bulkhead"** and **v2.7.6 "Recount"** and **v2.7.5 "Tally"** and **v2.7.4 "Pocket"** and **v2.7.3 "Hearth"** and **v2.7.2 "Bankroll"** and **v2.7.1 "Keepsake"** and **v2.7.0 "Palisade"** and **v2.6.23 "Pulse"** and **v2.6.22 "Rigging"** and **v2.6.21 "Steward"** and **v2.6.20 "Telltale"** and **v2.6.19 "Accession"** and **v2.6.18 "Errata"** and **v2.6.17 "Terminus"** and **v2.6.16 "Interlock"** and **v2.6.15 "Warrant"** and **v2.6.14 "Docket"** and **v2.6.13 "Slack"** and **v2.6.12 "Groundwork"** and **v2.6.11 "Exposure"** and **v2.6.10 "Inference"** and **v2.6.9 "Abeyance"** and **v2.6.8 "Arrears"** and **v2.6.7 "Detent"** and **v2.6.6 "Chassis"** and **v2.6.5 "Muster"** and **v2.6.4 "Rubric"** and **v2.6.3 "Mainspring"** and **v2.6.2 "Witness"** and **v2.6.1 "Interleave"** and **v2.6.0 "Assay"** and **v2.5.9 "Overture"** and **v2.5.8 "Blanking"** and **v2.5.7 "Collimation"** and **v2.5.6 "Vestige"** and **v2.5.5 "Raster"** and **v2.5.4 "Escapement"** and **v2.5.3 "Hysteresis"** and **v2.5.2 "Dormant"** and **v2.5.1 "Retrace"** and **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a rolling patch cadence rather than maintaining long-lived release branches, so security fixes land in the next patch release rather than being backported. Report against the latest release or `main`. diff --git a/SUPPORT.md b/SUPPORT.md index 452be26b..b79aef02 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -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 **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** +A: Yes. RustyNES is well past its first stable release — the current release is **v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** **Q: How accurate is RustyNES?** diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md index 54a4f789..99d02c1d 100644 --- a/VERSION-PLAN.md +++ b/VERSION-PLAN.md @@ -1,6 +1,6 @@ # RustyNES Version Plan -**Current release: v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. **No hardware has run any bitstream.** +**Current release: v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. **No hardware has run any bitstream.** RustyNES follows [Semantic Versioning 2.0.0](https://semver.org/). @@ -166,7 +166,8 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide | **v2.7.6 "Recount"** | The v2.7.5 deletions measured one at a time -- a measurement release between the v2.7.x and v2.8.x audit lines. v2.7.5 bounded six of the core audit's proposals (§3.1 A/B/C, the IMP-06 stores, §3.5b, §3.6) only in one combined ceiling; each was re-run alone, twice, with the A/B/A control. Five are zero; §3.5b had never been reached (the bench ROMs are silent, 0 sweep-mute checks per frame), and on `spritecans.nes` bounds at about 0.2%, with the cheap byte-identical candidate 0.67% slower, so it is rejected. Three of the IMP-06 stores re-wrote fields the fast path's guard already requires, and are now a `debug_assert!` pinned by a unit test (no ROM reaches the violating state). §3.1 B and C are dead work that goes with the v2.9.0 deprecation decision. Also ships #554 (contributed): the macOS buildbot jobs build again, and 32-bit Windows, 32-bit Linux and webOS targets were added. Plan: `to-dos/plans/v2.7.6-recount-plan.md` | | **v2.8.0 "Bulkhead"** | The libretro core stops a fault at its own boundary -- the first release of the v2.8.x audit line, from the libretro audit's FFI-safety findings (L-1.1 to L-1.5, L-2.1, L-2.2, L-2.4), each shown failing in a new C-ABI harness that drives the core as a frontend does. The core was built `panic = "abort"`, so its handlers were dead code; it now unwinds on every supported build path (+0.1% instructions per frame; a direct `cargo build --release` still aborts, and now warns and logs it), and every emulating callback contains a panic, stops the game and keeps the console so RetroArch's memory pointers stay valid. `serialize_size` reserves room for the largest expansion device on both ports (a Zapper no longer breaks every later save) and the reader treats an all-zero tail as padding; unload withdraws the memory maps; the vendored `rust-libretro-sys` writes out `retro_game_info`, so the core loads without `GET_GAME_INFO_EXT`. The save state gains the 2A03 internal data bus (older states still load), and the schema audit now covers the bus. No emulation behaviour change; AccuracyCoin 144/144, nestest 0-diff. Plan: `to-dos/plans/v2.8.0-bulkhead-plan.md` | | **v3.0.1 "Mortar"** | A maintenance release on v3.0.0, plan [`v3.0.1-mortar-plan.md`](to-dos/plans/v3.0.1-mortar-plan.md). Mapper 45's CHR-RAM is unbanked, so *Famicom Yarou Vol.1* draws its menu (T-GA23C-CHRRAM; `EMULATION_EPOCH` 2 refuses v3.0.0 movies and netplay peers). The MiSTer core's dot-0 A12 rule is gated on whether cycle 0 was rendering, found by a new odd-frame stimulus (`mapper4mmc3oddskip080`, plus an /IRQ-rise comparison); new bitstreams at seed 2 / 261007, a release candidate, not hardware-verified. Rust 1.99 and every dependency at its newest, the libretro buildbot included once test pipeline 119614 passed 15/15. Every bot review left unanswered since PR #1 answered (473 verdicts, 80 fixes). The shared Bisqwit NTSC pass recorded as derived; the TriCNES source moved out of the repository. The plan to v4.0.0 written from 29 maintainer decisions | -| **v3.1.0 "Bellwether"** (current) | The first release of the v3.1 to v4.0 line, plan [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md). AccuracyCoin re-synced to upstream `f5f41dc2`, 146 of 146; the new ROM showed that every earlier 100% included a `Misaligned OAM behavior` failure the old ROM hid, and both defects it found are fixed (`EMULATION_EPOCH` 3, BUS section 3, `PPU_SNAPSHOT_VERSION` 13, `.rnm` 6, netplay 7: v3.0.1 states, movies and peers are refused). The CPU overclock and the sprite-limit option work and ride in `HardwareOptions`; PAL and Dendy emphasis swap red and green; the alternate MMC3 is selectable and asserts on a `$C001` reload to 0; differential phase in the NTSC decode; rewind and run-ahead on the Vs. cabinet; an epoch fingerprint gate. The MiSTer core matches the re-synced battery on all 146 entries (the misaligned X mask, the DMC reload edge), matches the emulator on a `$2006` copy landing on every dot (RTL-1, provisional until a board settles it), gates 30 of 34 AccuracyCoin sub-test ROMs, and moves its pin to v3.1.0. **No hardware has run any bitstream.** | +| **v3.2.0 "Confluence"** (current) | The second release of the v3.1 to v4.0 line, plan [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md), and every v3.2.0 branch in both repositories brought into one. 17 mapper families (191 to 208, among them the Bandai Datach with its barcode reader), 47/121/154/191/243 promoted to Curated, tiers 51 / 130 / 27 (181 of 208 accuracy-gated). The VRC4 IRQ mode bit is read the right way round, VRC2 mirroring has its two settings, *Fire Hawk* keeps single-screen mirroring on an iNES 1.0 header, mapper 290 boots and the 24C02 keeps its word address across a START (`EMULATION_EPOCH` 4: v3.1.x movies and peers are refused). An AI remediation branch (61 findings) audited change by change: 20 kept, 14 fixed, 4 reverted, 10 rejected, its APU speed-ups kept byte-identical and the 47% expansion-audio slowdown it introduced removed. Netplay protocol 8 (`"RNE8"`), libretro states of about 16 KB, owner-only save files. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line (on-die) and custom palettes, moves its pin to v3.2.0 (842 golden artifacts reproduce byte for byte), and ships bitstreams at fitter seed 2. **No hardware has run any bitstream.** | +| **v3.1.0 "Bellwether"** | The first release of the v3.1 to v4.0 line, plan [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md). AccuracyCoin re-synced to upstream `f5f41dc2`, 146 of 146; the new ROM showed that every earlier 100% included a `Misaligned OAM behavior` failure the old ROM hid, and both defects it found are fixed (`EMULATION_EPOCH` 3, BUS section 3, `PPU_SNAPSHOT_VERSION` 13, `.rnm` 6, netplay 7: v3.0.1 states, movies and peers are refused). The CPU overclock and the sprite-limit option work and ride in `HardwareOptions`; PAL and Dendy emphasis swap red and green; the alternate MMC3 is selectable and asserts on a `$C001` reload to 0; differential phase in the NTSC decode; rewind and run-ahead on the Vs. cabinet; an epoch fingerprint gate. The MiSTer core matches the re-synced battery on all 146 entries (the misaligned X mask, the DMC reload edge), matches the emulator on a `$2006` copy landing on every dot (RTL-1, provisional until a board settles it), gates 30 of 34 AccuracyCoin sub-test ROMs, and moves its pin to v3.1.0. **No hardware has run any bitstream.** | | **v3.0.0 "Cornerstone"** | The API major -- the first release of the v3 line (ADR 0043). It gathers every break since v2.x in one place (the header-free game identity, refused older states and movies, the protocol and API changes of v2.8.x-v2.9.9) and adds the last: a core timing epoch that movies (`.rnm` format 5) and netplay (protocol 6, `"RNE6"`) carry, so a version that emulates differently is refused with a reason (ADR 0045); `Cartridge`, `BoardDescription`, `HardwareOptions` and `Movie` `#[non_exhaustive]`; `MapperError::Truncated` renamed `WrongLength`. T-MMC3-BG-A12 closes blargg's `4-scanline_timing` (13/13) in the emulator and on the MiSTer core (`PPU_SNAPSHOT_VERSION` 12). Also: the spectator buffer bounded, mapper 45's power-on CHR-AND (two *Famicom Yarou* menus), the mobile apps on the workspace version, and `CI success` that cannot pass untested. The MiSTer bitstreams ship as a release candidate, not hardware-verified; verification moves to v3.x | | **v2.9.9 "Ballast"** | The release candidate for v3.0.0 -- the tenth release of the v2.9.x line and the last of the line to v3.0.0. All four audit scopes re-run (0 rows regressed; NC-09..17, NF-11..23, NL-11..15, NR-13..17 fixed or decided, NC-17 and NR-14 by the maintainer). T-ORACLE-001 fixed to `4-scanline_timing` sub-test 9: the oracle's `$C001` reload discriminator was the late IRQ; the NESdev rule plus a one-cycle IRQ output deferral replace it (MMC3 section v4). T-MMC5-8X8-SET fixed and wider than the ticket: the MMC5 decodes `$2000`/`$2001` itself and follows the page's CHR table and `$2007` rule, which also fixed *Uchuu Keibitai SDF* (T-COMMERCIAL-GARBLE closed; MMC5 section v6). Audio exact across a save state (APU v5, NL-12). Movie format 4 (NC-10). Mobile save keys migrated to the v2.9.8 identity (NF-21). The sibling's oracle pin moved v2.9.2 -> v2.9.9 with every changed golden attributed, the APU `$4017` / scanline-0 parity in RTL, a frame-sequencer stall found and fixed, and `apu_test` 1-10, `mapper4mmc3irq065` and four checkpoint streams gated. RC bitstreams at seed 6 (261005), byte-identical double compiles. T-GA23C-POWERON searched and left open. | | **v2.9.8 "Vanguard"** | The preparation release for v3.0.0 -- the ninth release of the v2.9.x line. v3.0.0's planned breaks landed early at the maintainer's direction (ADR 0042/0043 amendments of 2026-10-01; the number stays v2.9.x): `Nes::rom_sha256` hashes the bytes after the header, so earlier saves, cheats and movies are not found; `.rns` epoch 3 and BUS section 2 refuse older states with every legacy reader removed; `.rnm` format 3 and the netplay handshake carry every emulation option (ADR 0044) and Four Score P3/P4; `SystemBus` (was `LockstepBus`), the 2.7.5 deprecations, `serialize_header` and the dead /NMI detector removed, `Header` and `FrameInput` `#[non_exhaustive]`; the Vs. database keyed by the new identity. Every one of 748 staged dumps booted and looked at: fixed from their documentation were the game database rewriting correct NES 2.0 headers (10 games), the iNES 1.0 dirty-tail mapper nibble, mappers 19, 64, 78, 105 (`$4017` inhibit), 153, 191 (non-power-of-two images), 218 and 226, Vs. work RAM and the Goonies palette, and PAL regions now reach iNES 1.0 games. The game database's corrections now reach Android, iOS and libretro; power-on options apply before a game's first frame; Power Cycle keeps the PPU/APU settings; an opt-in Famicom console model; the documented NTSC emphasis model. Performance: the /NMI removal -4.9% to -6.1% on three workloads, three campaign candidates adopted (-1.8% to -4.3% together); end to end only shipped `nestest_fast` is established (-5% to -9% vs v2.9.7), the rest a v2.9.9 lead. MiSTer: aspect/integer-scale/crop/2C03-palette menu options (not seen on hardware), a palette-gate, seed 2 kept after re-sweeps. `cargo test --release --workspace --features test-roms,commercial-roms` 3,367 passed / 0 failed; AccuracyCoin 144/144, nestest 0-diff; co-simulation 175/0/1 on-die, 176/0/1 off-die. **No hardware has run any bitstream.** Plan: `to-dos/plans/v2.9.8-vanguard-plan.md` | diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d9410d1f..98757644 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,8 +65,8 @@ android { // `scripts/release-automation/bump_release.py` from now on, starting // with the 3.0.0 cut. versionCode = MAJOR * 10000 + MINOR * 100 + // PATCH, so 20909 still rises past 20004. - versionCode = 30100 - versionName = "3.1.0" + versionCode = 30200 + versionName = "3.2.0" // No abiFilters here — set per buildType so release ships arm64 only // while debug keeps x86_64 for the emulator. // PLAY_BUILD is set per-flavor below (`false` for `foss`, `true` for `play`), diff --git a/crates/rustynes-cosim/Cargo.lock b/crates/rustynes-cosim/Cargo.lock index 03fa409a..41d74a11 100644 --- a/crates/rustynes-cosim/Cargo.lock +++ b/crates/rustynes-cosim/Cargo.lock @@ -98,7 +98,7 @@ dependencies = [ [[package]] name = "rustynes-apu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "libm", @@ -107,7 +107,7 @@ dependencies = [ [[package]] name = "rustynes-core" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "lz4_flex", @@ -121,7 +121,7 @@ dependencies = [ [[package]] name = "rustynes-cosim" -version = "3.1.0" +version = "3.2.0" dependencies = [ "rustynes-core", "sha2", @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "rustynes-cpu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "thiserror", @@ -137,7 +137,7 @@ dependencies = [ [[package]] name = "rustynes-mappers" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "rustynes-apu", @@ -146,7 +146,7 @@ dependencies = [ [[package]] name = "rustynes-ppu" -version = "3.1.0" +version = "3.2.0" dependencies = [ "bitflags", "libm", diff --git a/crates/rustynes-cosim/Cargo.toml b/crates/rustynes-cosim/Cargo.toml index ac687595..0286ca73 100644 --- a/crates/rustynes-cosim/Cargo.toml +++ b/crates/rustynes-cosim/Cargo.toml @@ -8,7 +8,7 @@ description = "RustyNES as a co-simulation oracle for an external HDL device-und # The duplication is PINNED, not merely noticed: `cosim_manifest_audit.rs` in # `rustynes-test-harness` asserts these values still match the workspace's, so # drift fails a test instead of accumulating quietly. -version = "3.1.0" +version = "3.2.0" edition = "2024" rust-version = "1.99" license = "GPL-3.0-or-later" diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info index c5c2500c..847c29de 100644 --- a/crates/rustynes-libretro/rustynes_libretro.info +++ b/crates/rustynes-libretro/rustynes_libretro.info @@ -5,7 +5,7 @@ supported_extensions = "nes|fds|unf|unif" corename = "RustyNES" license = "GPLv3+" permissions = "" -display_version = "v3.1.0" +display_version = "v3.2.0" categories = "Emulator" # Hardware Information diff --git a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv index f50c37d7..906e27f8 100644 --- a/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv +++ b/crates/rustynes-test-harness/golden/epoch_fingerprint.tsv @@ -4,7 +4,7 @@ # the last release shipped, set by hand at each release cut: output may # move only while EMULATION_EPOCH is above it. # epoch 4 -# last_release_epoch 3 +# last_release_epoch 4 # rom frames framebuffer_fnv audio_fnv ram_fnv cpu_cycles accuracycoin/AccuracyCoin.nes 3200 e5edb57f59c38538 47e9857638ba5d28 b93b7feacb471f53 95268158 blargg/apu_mixer/triangle.nes 600 1b6dac56c42f267a 87f291ef58273cac 983e19fe15e197d6 17838587 diff --git a/docs/STATUS.md b/docs/STATUS.md index 15ebcc9b..4a23ae5f 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,6 +1,6 @@ # RustyNES — Project Status Matrix -> **Current release: v3.1.0** (2026-10-08) — **"Bellwether"**, the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** (2026-09-29) — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** (2026-09-29) — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** (2026-09-29) — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. The mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). Built on **v2.9.2 "Candidate"** (2026-09-28) — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** (2026-09-27) — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** (2026-09-26) — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** (2026-09-26) — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** (2026-09-25) — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** (2026-09-25) — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** (2026-09-25) — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** (2026-09-25) — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** (2026-09-24) — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** (2026-09-24) — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** (2026-09-24) — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** (2026-09-23) — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** (2026-09-23) — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** (2026-09-23) — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** (2026-09-23) — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** (2026-09-20) — the access does not increment, it pulses the load already there. A `$2007` access during rendering pulses the rendering pipeline's existing load rather than computing a private increment, and resolving against that same value is what closed the CHR-during-rendering divergence in the MiSTer sibling: `chrram-live` went from **32,861 of 61,440 differing pixels**, red for eight releases, to "All 61440 pixels match", and `chrram-fetch` from 21,941 to 18. Eleven prior variants had been measured correctly and the conclusion drawn from them was wrong — they swept what the `$2007` arm computes and never what it computes it from. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction** and were re-run anyway. The bitstream is re-cut at fitter seed 4 (+0.421 ns setup / +0.112 ns hold) and **no hardware has run it**. The board session is now **v2.9.2** and the hardware-verified core **v3.0.0**, after three audit lines ([ADR 0041](adr/0041-hardware-release-is-v3.0.0.md)). Built on **v2.6.22 "Rigging"** — the instruments for the board, built before the board. **No hardware has run any bitstream**; the session with the board (then planned as "Shakedown"; since ADR 0041, v2.9.2) is the hardware half and this is the non-hardware half of it, cut separately so that name keeps meaning what it says. AccuracyCoin now reads back from hardware as BYTES rather than as a photograph — a mirror ROM copies its result vector into the battery-backed save window, with a byte budget that proves the patch moved nothing and a simulation control that proves it changed no answer. The catalog turned out to have **149 rows and 144 results**: five `Power On State` rows share upstream's omit-sentinel, and counting them made the headline a function of *when the run was sampled*. **144/144 and nestest 0-diff are unchanged** — the count stopped depending on the sampling instant. And the `46199ae4` re-sync turned out to have been half-done: three goldens described a ROM no longer in the tree, which no gate could see because `rom_sha256` was written into every manifest and compared to nothing. Built on **v2.6.21 "Steward"** — the board arrives, and the core is not ready for it. Battery saves exist for the first time; the CHR-during-rendering gate the retrospective audit asked for is added and is **RED at 32,861 of 61,440 pixels**, with the obvious fix measured insufficient at 715 recovered; two gates existed that nothing ran; and the deploy loop the runbook prescribed was built, which found two defects in its own spec. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.20 "Telltale"** — the counter had no reader, and two knobs turned out to be one decision. The FPGA core's OAM2 address counter was write-only for a release, so `$2004`'s post-fetch rest value was a hardcoded index 0; making it observable took the DUT to **AccuracyCoin 149 of 149** and the co-simulation ladder to **150 of 150, 0 failed**. Restoring the increment window v2.6.19 had narrowed then broke sprite rendering by six pixels, because the window and the deleted dot-257 latch are ONE decision with two self-consistent answers and only one of them is the hardware's. The shipped configuration restores the latch: window 256, the dot-257 freeze latch back and consumed by sprite fetch, and `$2004` reading the live counter. A CHR-RAM write gate was added. **v2.6.21 corrects this sentence: it did NOT close the coverage the audit named.** The audit named writes *while rendering is enabled*, and PROGRAM53 writes with `PPUMASK = 0` and renders afterwards. The real case is gated at v2.6.21 and is RED -- 32,861 of 61,440 pixels. The emulation core is unchanged, so **144/144 and nestest 0-diff hold by construction**. Built on **v2.6.19 "Accession"** — the DUT absorbs two releases of oracle behaviour, and the seed rule catches something for the first time. The FPGA core implements the OAM2 address counter it never had and stops acting on a `$2001` change that lands during dot 256 -- AccuracyCoin on the DUT **148 of 149**, the ladder **147 of 147**. The fitter pin is re-derived over ten seeds and stays at 3. (The contemporaneous claim that seed 1 stopped closing is withdrawn -- that sweep recompiled in place and was order-dependent; seed 1 closes, and seed 8 is the one that does not.) The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.18** (2026-09-12) — **"Errata"**, the recorded cause was wrong in three ways, and the last AccuracyCoin entry closes at 144/144 -- a `$2001` change during dot N must not act on dot N, at the dot-256 vertical increment and the dot-339 OAM2 reset. Built on **v2.6.17** (2026-09-11) — **"Terminus"**, the accuracy battery grows to 144 assigned tests and one of the new ones names the dots a write lands on -- this core is two dots early, measured twice on two independent `$2001` writes, and moving it is refuted by the gate this version wrote in advance, so the documented compensation stays and 143 of 144 ships. Built on **v2.6.16 "Interlock"** (2026-09-04) — the arbiter's numbers describe a stimulus, not the console -- and the console meets the CPU's deadline at zero margin, on every one of 240,303 requests, where the gate said it missed by four. Built on **v2.6.15 "Warrant"** (2026-09-04) — the claims v2.7.0 will make become checkable, and the instrument pays the oracle back. Built on **v2.6.14 "Docket"** (2026-09-03) — the submission checklist becomes auditable, and auditing it finds five boxes already true, two ticked on evidence that expired, and one that could never have been ticked honestly. v2.7.0 IS the submission, so the list in `to-dos/mister/contribution-checklist.md` decides whether the core is ready -- and it had 30 boxes, 16 unticked, and FOURTEEN OF THOSE SIXTEEN saying nothing at all about why. That ambiguity is the defect: an unticked box with no reason cannot be told apart from work outstanding, work blocked outside this repository, and WORK ALREADY DONE AND NEVER TICKED, and the third case occurred five times -- the provenance CI job, the SPDX sweep, the firewall statement, the AccuracyCoin vector and the preservation-value case were all true and all unticked, so the list reported the project as further from submission than it is by a fifth of its own length. ONE BOX COULD NEVER HAVE BEEN TICKED HONESTLY: it asked that `docs/provenance.md` state "that no NES core was ever opened", and that same document's section Do not self-certify forbids exactly that class of finished claim, so satisfying the box required writing the one sentence the provenance rules exist to prevent -- a wrong requirement rather than a missing tick, and only reading every item found it. RE-MEASURING THE TICKED HALF found two more, which is v2.6.9's lesson in a different document: `RustyNES.sdc` still said "there is exactly one core clock", true of v2.6.3 and false since v2.6.13, which added a 4x SDRAM clock and a phase-shifted pin clock that the shipped timing report names alongside it; and the `.qsf` entry said all 109 pin assignments come from `sys/sys.tcl`, where 109 is what that script supplies before stopping short of the I/O board, so a core must also source one of two 36-assignment variants -- 145 in total, from two scripts. THE ALARMING READING OF THE FIRST WAS CHECKED BEFORE IT WAS WRITTEN DOWN: it looks as though the framework's `set_clock_groups -exclusive` might be cutting the console-to-SDRAM crossing and leaving ADR 0039's safety argument unfalsifiable, and it is not -- exclusive cuts paths BETWEEN groups, all three outputs match one glob, and v2.6.13's own -24.769 ns measurement of that crossing is only observable because those paths are analysed. THE NAMING DIVERGENCE IS MEASURED RATHER THAN PARAPHRASED, from `Main_MiSTer/file_io.cpp` instead of the wiki: `get_display_name` searches for the literal underscore-two-zero and TRUNCATES THE DISPLAY NAME THERE, taking the rest as a datecode, and `DirentComp` groups by that truncated name -- so a version-named bitstream makes every release a SEPARATE CORE ENTRY, named for the version rather than the core, ordered alphabetically, which puts v2.6.9 after v2.6.13. The fix is one line and is NOT taken here, because version-naming is a maintainer decision with a stated rationale and reversing it is not an audit's call. THE TASK BOARD HAD THE SAME DEFECT AND ONE ROW WORSE: four delivered items never ticked, a hardware row still naming a version seven releases past, and an SDRAM row whose precondition -- "after a board exists" -- v2.6.13 simply did not follow, having accepted the controller against a behavioural model written from the datasheet instead; that is rung 7's own recorded lesson repeating one row below where it is written, since the blocker applied to hardware ACCEPTANCE rather than to building the thing. A CLAIM v2.6.13 SHIPPED IS RETRACTED: answering a review finding, I wrote that MMC1 was the sixth approved family and not yet implemented, and the cartridge decodes mapper 1 at three sites with a registered gate green since rung 7 opened -- asserted from memory inside a reply correcting somebody else's reading of the same line. The gate that keeps all this true asserts a SHAPE rather than a judgement and is demonstrated by five mutations, one of which proves the continuation-line folding load-bearing by producing nine false violations without it. The bitstream is BYTE-IDENTICAL to v2.6.13's, which is the point: the only sibling change is a comment, and an identical artifact demonstrates it. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.13 "Slack"** (2026-09-03) — the cartridge outgrows the die, and three consumers want the same bus. An SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge, all written from the AS4C32M16SB-7 datasheet revision 1.4 -- no third-party controller read, ADR 0037 applying. The budget the previous step worked to was a single figure read off the fetch structure and never measured; `nes_top`'s `CHR_LAT` sweep asks the console directly, and there are THREE answers: a background or sprite fetch tolerates 28 cycles and uses 17, the PPUDATA data port tolerates 8, and the CPU sampling at mc7 has 24 -- four cycles of every budget going to the console-domain crossing, which is not optional, because publishing a runtime modulo combinationally into an 11.64 ns domain costs -24.769 ns of setup. The PPUDATA port could never fit, half its budget being the crossing, so it leaves the shared bus entirely: `ppu2c02` gains `BUFFER_HANDSHAKE` and fills its read buffer through a port of its own on the arbiter, affordable because the CPU does not read that buffer until its next PPUDATA access. THAT FIX SHIPPED A DEFECT ONLY A BANKED CARTRIDGE COULD SEE -- the request carried the RAW fourteen bits the PPU presents, which is right by coincidence on NROM because the mapper's translation is the identity there, and reads BANK ZERO on everything else; `ppu-misc-2007-stress` passed off-die throughout while the two CNROM gates read a zero byte where the oracle reads one, one and two. The fix REMOVES the address rather than correcting it: `cart.sv` already publishes the translation for the fetch path, so the request carries none and there is one source of truth for where CHR lives instead of two that agree only on NROM. A DELETION WAS THEN REFUTED BY ONE CYCLE: with the address fixed, a control issuing at a flat +7 passed both gates, which read as the anti-contention deferral buying nothing, so it was removed -- and the deployed code issued at +6, and both gates failed again. The control and the code differed by a single cycle, which is the measurement of how thin the CPU's off-die deadline is and the concrete argument for scheduling the bus rather than arbitrating it. Two INFERRED LATCHES that Verilator cannot see: `ppu2c02` assigned two signals only under `BUFFER_HANDSHAKE`, so in the shipped on-die build the only assignment either reached was the reset branch, and a variable holding its previous value on every live path is a latch -- Quartus said so twice while the lint gate stayed green, and the comment beside them asserted the defect as a virtue ("outside BUFFER_HANDSHAKE neither signal ever moves", which is true and is exactly the condition). And a defect in the HARNESS: `USE_SDRAM` reaches Verilator as `-G` rather than as a file, so make ran ONE binary under both configurations' names and a log labelled on-die was the off-die build, reproducing the off-die failures exactly -- closed with a stamp-file prerequisite, demonstrated by mutation. THE OPEN ROW: accesses no longer auto-precharge, a hit costs 6 cycles against 10 for a miss, tRAS's 120 us MAXIMUM is respected by an early close in idle, and two defects came out of the rewrite -- re-entering idle with the request still asserted issued every access TWICE, and subtracting one from CAS the way the other waits are computed broke every read to all zeroes, CAS being "data appears at cycle N" rather than a command-to-command gap. Two MiSTer tickets close: `sdram_sz` is consumed VALIDITY BIT FIRST, so `absent` is deliberately not `!present` and a power-on all zeroes cannot read as "no board" (gated exhaustively over all 65,536 values), and `status_menumask` is computed rather than tied off, greying Reset whenever the console is already held in reset. OFF THE DIE THE CONSOLE PASSES 142 OF 142, every gate the on-die build passes, with better timing margin and 384 fewer M10K blocks -- and it still SHIPS ON the die, because an off-die core cannot run at all without the SDRAM add-on while rung 7's five mapper families fit on the die at 468 of 553 blocks. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.12 "Groundwork"** (2026-09-02) — the bitstream was an NROM-only console. Rung 7 landed five mapper families and 142 co-simulation gates verify them, and the layer that turns that RTL into a bitstream was never told: `rtl/emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND -- mapper 0 for EVERY cartridge, `prg_8k_count = 0` collapsing PRG to an 8 KiB window, and CHR forced to RAM. The declared 256 KiB PRG and 128 KiB CHR were implemented as 8 KiB each; connecting three wires takes block memory from 666,061 to 3,680,717 bits and timing still closes at all four corners. NOTHING COULD HAVE CAUGHT IT: simulation cannot, because `emu.sv` is not in the testbench file list and the harness drives those ports itself, so all 142 gates exercised a correctly-configured cartridge; and Quartus DID say so three times, in messages that cite an INSTANCE path rather than a file and are absent from the "0 errors, N warnings" tally, so the existing checker read 0 of 125. Two gates close that -- one fails on an unconnected pin of any module this repository declares, the other pins the warning SET rather than its count -- and both are demonstrated to fail by mutation. The `hps_io` tie-off audit that followed raised nine `T-MISTER-*` tickets, and annotating each with a blocker turned "none of these landed" into a measurement: not one is blocked on EFFORT, so the list is the rung-6 agenda rather than a backlog. `T-MISTER-SAVE` was attempted and refuted -- every save route terminates in `hps_io`, which no gate here instantiates. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.11 "Exposure"** (2026-09-02) — a picture is a gate the ladder did not have. All 141 co-simulation gates THEN IN THE SUITE were green (it ends this release at 142, the one it added) and TWO OF SIX commercial games rendered wrong -- a CHR-RAM write was taking the shared-pin composite address built for FETCHES instead of `v`, so the layout was right and the tiles were scrambled. The split is exactly CHR-ROM against CHR-RAM, which named the mechanism before any tracing, and a CONTROL says it is not v2.6.10's regression: the pre-M10K-fix RTL differs by the IDENTICAL 16,565 pixels, so the defect dates from the cartridge landing in v2.6.9. It was not UNREACHED -- the DUT asserts `chr_wr` 9,600 times in the Battletoads run -- it was UNCOMPARED: only THREE of the 141 gates compare a framebuffer, all three ship CHR-ROM, every other gate is CPU-side, and AccuracyCoin, the widest gate in the suite, is CHR-ROM too. The rung-7 gates' own comment says what they are for -- "these gates are about BANKING and nothing else" -- and it was accurate, and it was the whole coverage. Six commercial titles now render byte-identically to the oracle over all 61,440 pixels, published as a montage built by a script that REFUSES to publish a tile that differs from the oracle. The v2.6.10 bitstream carries the defect and a published version is immutable, so the corrected `.rbf` ships here. The same release finds EIGHT release leads describing v2.6.10 with v2.6.9's summary, and v2.6.9 gone from the lineage entirely, with `docs/STATUS.md` naming v2.6.10 under the codename "Abeyance" -- every existing check passing CORRECTLY, because they pin the version TOKEN and the token was right. Prose cannot be audited; an ORDERING can, so two gates are added and both are demonstrated to fail by mutation. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.10 "Inference"** (2026-09-01) — the cartridge meets the synthesiser. Five cartridge boards verified across 141 co-simulation gates had **never been through Quartus**, and Analysis & Synthesis refused the design: `chr` was written from TWO `always_ff` blocks, which cannot infer as one M10K, so 128 KB of CHR stayed in flip-flops -- **1,048,576 registers against roughly 166,000**. Simulation cannot ask this question: Verilator accepts both forms without complaint. It is v2.6.6's finding one layer out -- that release established that an M10K read is REGISTERED, this one that a correctly registered memory still will not infer with two writers. The fitter was also throttling itself under Auto Fit while the `.qsf` carried no optimisation assignments at all: at full effort **all six seeds close** where two had failed, so the effort settings move the whole distribution across zero and the seed only picks where in it you land -- and the project had been pinned to seed 4, the WORST of the six. Pinned at seed 3, +0.531 ns setup and +0.099 ns hold, byte-identical across two independent compiles. The bitstream v2.6.9 could not produce ships here. Built on **v2.6.9 "Abeyance"** (2026-08-31) — an exclusion hides improvement as well as regression, and both denied co-simulation streams close. The larger one was never the console: `apuconflict039` had been carried for seven releases as a declared diagnostic whose bus surface "carries nine divergences BY DESIGN", and the nine were a defect in the HARNESS -- on a cycle the CPU is held, the testbench built its record's bus data from a stale local rather than from the RTL's own latch. Taking it from the latch makes the stream IDENTICAL on all 357,361 overlapping cycles and all 88 checkpoints, and the local is now dead and deleted. The phrase "by design" is what stopped anyone re-checking it, because it reads as a property of the thing under test when it was a property of the instrument reading it. The other stream differs on EXACTLY ONE cycle, a documented and attributed OAM-corruption asymmetry -- and carrying that needed an instrument the suite did not have, because the PLANNED mechanism was refuted by its own mutation pass: an allowance by checkpoint index cannot work on a rolling hash, since one divergent cycle poisons every checkpoint after it, so allowing the first differing window simply moved the failure to the next one and allowing the rest is the all-or-nothing deny it was meant to replace. A per-cycle nine-field comparator with a scoped allowance costs ONE cycle of coverage instead of seventy-one checkpoints -- 357,360 of 357,361, against nothing at all before -- and it fails BOTH ways, so a DUT that improves cannot leave a stale allowance quietly hiding coverage; six mutations confirm it, including a cycle outside the compared window being REFUSED rather than allowed to match nothing. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction and were re-run anyway. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.8 "Arrears"** (2026-08-31) — the gates the previous release fixed and never widened. A deny list is an assertion about the THING UNDER TEST, so v2.6.7 changing both the DUT and the harness re-opened every exclusion -- and nothing re-measured one. FOUR of the six denied checkpoint streams were passing (`irqlat048`, `ppuvbl023`, `ppuvbl024`, `ppuvbl025`), and THREE of those were not run by the suite at all, so removing them from the deny list was INERT until they were also iterated. The nestest gate compared 265,000 cycles against a 5,062,688-cycle golden -- correct while caveat C6 was open, left behind the moment it closed -- and all 5,062,680 overlapping cycles now match, with the window DERIVED from the manifest. CAVEAT C4 CLOSES by demonstration: nestest raises `nmi_line` on 3,592 cycles and had no nine-field comparison at all, so wiring it makes an `o.nmi_line` mutation CAUGHT at checkpoint 28 of 1,237 while the bus gate passes the identical run. Suite 128 passed, 0 failed, 0 skipped; nine-field comparisons 51 -> 57. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no board is attached, confirmed by checking. Built on **v2.6.7 "Detent"** (2026-08-30) — the bitstream becomes a published release artifact and a one-cycle disagreement is pinned to the cycle it happens on. Every release from here ships a `.rbf` -- committed to the sibling's `releases/` and attached to the GitHub release on BOTH repositories -- reversing v2.6.6, which produced one and withheld it because no hardware had run it: the MiSTer distribution mechanism reads that path out of the REPOSITORY, so an empty `releases/` describes an undistributable core rather than a cautious one, and the caution moves from an absence into a disclosure naming what the ladder cannot reach by construction (the PPU gate compares the pre-palette index and the APU gate per-channel integer levels, so the palette, the video timing constants, the audio absolute level and its band-limiting all sit downstream of every gate). The build is REPRODUCIBLE and that is now measured rather than argued -- a from-scratch compile and an incremental one produce a byte-identical bitstream -- which is also how v2.6.6's published slack figures came to be WITHDRAWN: no corner of a clean rebuild reproduces them, the innocent explanation (a different timing corner) was checked first and refuted, and the correct pair is +0.108 ns setup and +0.042 ns hold at the binding corner. THE RELEASE GATE WAS READING THE WRONG CORNER -- Slow 100C is not the binding one on this design, so a bitstream failing at Slow -40C would have passed while the gate reported three times the real margin -- and the checker that reads it was wrong twice before mutation found both: it first extracted ZERO rows from both summary tables and reported that as "no negative slack", then, once fixed, reported FOURTEEN clocks from a report emptied of its data, having run past the closing rule into the next tables. Caveat C2 splits in two. The first residual was a TRACE OBSERVATION POINT -- the harness built its record after eight of a CPU cycle's twelve master clocks while the oracle reads at end-of-cycle, and the frame-counter interrupt asserts on the final edge -- and closing it took checkpoint comparisons from 3 to 11 and failures at one checkpoint from 30 to 3. The second is REAL, and the FIRST fix for it was REFUTED in a way that found the right one: moving all four effects of the write one cycle later to match the oracle drops blargg from 11/11 to 4 of 11, one of the seven being the ROM written to probe exactly that timing. Read as a measurement that PROVES the sequencer's maturation is correctly placed, which leaves only the other effects the same write schedules -- so separating ONLY the interrupt clear lands it at write+3, the documented cycle, while the frame counter's zeroing stays put. Checkpoint streams go from 11 identical to 52 of 58 and the suite from 87 to 122, with blargg still 11/11 and the bus still matching on all 2,680,239 overlapping cycles. The checkpoint gate is registered over 51 comparisons and STATES ITS BLIND SPOT: not one gated golden ever raises an NMI, so it cannot catch an nmi_line defect, and the attempt to close that hole found a FOURTH divergence cluster that three goldens had been hiding inside a "26 skipped" tally line. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.6 "Chassis"** (2026-08-29) — the console becomes a MiSTer core -- `sys/` vendored byte-identical against `Template_MiSTer@3ea1134c` (57 files, 0 content differences), a top level, a clock, a palette, video sync and an audio mixer, compiled by Quartus 17.0.2 into a Cyclone V bitstream with 0 errors, timing CLOSED, and a warning count taken from 111 to THREE -- all three inside the vendored framework or Quartus's own megafunction, none of them citing this project's RTL (worst setup +0.086 ns and worst hold +0.096 ns at the binding corners, seed 3 -- v2.6.7 also withdraws the +0.363/+0.245 v2.6.6 published, which a clean rebuild of that configuration reproduces at no corner, TNS 0.000 on every clock; the console's own clock +13.514 ns at an Fmax of 30.26 MHz against the 21.477272 MHz it needs). The emulation core is unchanged, so the co-simulation suite is an ACCEPTANCE CRITERION rather than a formality -- 87 passed, 0 failed -- and it earned that immediately, because the cartridge memories had to be rewritten: an M10K read is REGISTERED, so 40 KiB of asynchronously-read cartridge was 393,216 registers against roughly 166,000 available, under a comment claiming it inferred block RAM from the source style alone, and the README had stated the correct rule since v2.4.3. Two defects were found only by asking whether the outputs would work on real hardware: the audio would have been a full-scale DC rail, because the mixer output is unipolar with silence at zero and the framework maps unsigned silence to -32768, and two OSD scanline options did nothing because VGA_SL was tied to zero. And a convention enforced by a glob has no error message: sys_top.sdc groups the core clock by matching the hierarchical name pattern *|pll|pll_inst|altera_pll_i|*, so a differently-named PLL matched no group at all and every crossing to the framework audio, HDMI and HPS domains was analysed as synchronous -- -13.901 ns of slack and -422,601 ns of TNS on a design whose Fmax was already above requirement, with the compile succeeding and the Assembler reporting 0 errors and 0 warnings throughout. Built on **v2.6.5 "Muster"** (2026-08-29) — rung 5 closes — the AccuracyCoin status vector is identical entry for entry across all 146 entries, with 146 of 146 executed on both sides and none NotRun, where the same gate read 5 of 146 at the version's start. A muster is a roll call where every name is called AND answered, which is the two-clause acceptance exactly. Five PPU defects close the last six differing entries and four were invisible to every gate that existed when the version opened: the background shift registers' RELOAD and their shift clock need SEPARATE gates (with one shared gate the serial-in test was not merely failing but ARITHMETICALLY UNREACHABLE, since reload dots are absolute and the reload discards the low seven bits, so a serial-in one can never reach bit 7 on any alignment — and modelling both structures reproduces BOTH measured shifter values); the sprite X counters are NOT gated on rendering, which AccuracyCoin states outright and the ROM that states it passes either way, because it expects no hit at X=254 and a sprite shoved 18 dots right is also off the line; the PPUADDR second-write v-copy is DELAYED, as the wiki says inside the write sequence itself, swept 1 to 4 dots against a control at 8 and 12 that fails; and the pre-render line CLEARS secondary OAM, without which scanline 0 draws what scanline 239 left — no sprite can ever render on scanline 0, because OAM Y is one less than the display row, and a sprite-0 probe over the full 134 M-cycle battery found 24 hits with four of them there; and the octal latch holding across the read dot, which is verified by exactly ONE gate and was unverifiable until the v-copy delay landed, the two composing the hybrid address together and neither producing it alone. A DIAGNOSIS IS RETRACTED: the residual was read as a two-dot CPU/PPU alignment error from comparing dot spans across two instruments, and at the committed alignment the two consoles execute identical pc, bus_addr and bus_access for 1,695,131 cycles while a two-dot shift moves the first fork back to 593,228 and takes the differing share from 5.13% to 66.80%. The oracle changes on the default path, so AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff are VERIFIED, not asserted. Built on **v2.6.4 "Rubric"** (2026-08-26) — OAM DMA lands and all nine AccuracyCoin disagreements close, every rule that closed the last three stated by the test ROM and by neither nesdev page — and then the gate that certified them is measured to cover 88 of 146 entries. The emulation core is unchanged. Built on **v2.6.3 "Mainspring"** (2026-08-25) — the DUT runs on one master clock, and four enables that were never enabling — plus AccuracyCoin end to end and a status vector that names its disagreements by test. The emulation core is unchanged. Built on **v2.6.2 "Witness"** (2026-08-24) — rung 4 closes: blargg APU battery 11/11 on the co-simulation DUT, six defects no self-written gate could see, and a suite that had been asserting nothing for five minor releases. The emulation core is unchanged. Built on **v2.6.1 "Interleave"** (2026-08-24) — the DMC and its DMA cycle steal in the MiSTer co-simulation DUT, cycle-exact on the bus. The emulation core is unchanged. Built on **v2.6.0 "Assay"** (2026-08-24) — the triangle, the noise channel and the sweep unit **in the MiSTer co-simulation DUT** — and an audit of how much of the APU was fitted to the oracle rather than derived from documentation. The emulation core is unchanged. Built on **v2.5.9 "Overture"** (2026-08-24) — rung 4 opens: the two pulse channels, the frame counter, and four ROM defects the stimulus measurement found first. Built on **v2.5.8 "Blanking"** (2026-08-24) — VBlank, NMI and the PPUSTATUS race close rung 3 — and both fixes were deletions. Built on **v2.5.7 "Collimation"** (2026-08-24) — sprite rendering closes exact — the phase was wrong by two dots, and every window was compensating. Built on **v2.5.6 "Vestige"** (2026-08-23) — Sprite evaluation closes: all 59,993 overlapping cycles match, nine of nine behavioural mutants caught and two proved inert (announced as seven of eight at the cut), and the fix is a byte index that outlives the walk that set it. Built on **v2.5.5 "Raster"** (2026-08-23) — the first full frame, and three blind spots in the stimulus that fed it. Built on **v2.5.4 "Escapement"** (2026-08-23) — the background fetch pipeline, and an access two dots early that five gates could not see. Built on **v2.5.3 "Hysteresis"** (2026-08-23) — toggling rendering takes effect three dots after the write, and four instruments to prove it. Built on **v2.5.2 "Dormant"** (2026-08-23) — the 2C02 register file, and a gate that passed while testing nothing. Built on **v2.5.1 "Retrace"** (2026-08-23) — the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned. Built on **v2.5.0 "Rungwork"** (2026-08-23) — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** (2026-08-23) — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** (2026-08-23) — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** (2026-08-23) — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** (2026-08-22) — the core learns arithmetic. Built on **v2.4.5 "Compass"** (2026-08-22) — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** (2026-08-22) — the first real RTL -- the 6502's eight-cycle reset and the implied opcode group, matching the oracle on all seven CPU fields (29 +> **Current release: v3.2.0** (2026-10-14) — **"Confluence"**, every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** (2026-09-29) — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** (2026-09-29) — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** (2026-09-29) — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. The mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29). Built on **v2.9.2 "Candidate"** (2026-09-28) — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** (2026-09-27) — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** (2026-09-26) — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** (2026-09-26) — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** (2026-09-25) — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** (2026-09-25) — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** (2026-09-25) — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** (2026-09-25) — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** (2026-09-24) — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** (2026-09-24) — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** (2026-09-24) — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** (2026-09-23) — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** (2026-09-23) — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** (2026-09-23) — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** (2026-09-23) — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** (2026-09-20) — the access does not increment, it pulses the load already there. A `$2007` access during rendering pulses the rendering pipeline's existing load rather than computing a private increment, and resolving against that same value is what closed the CHR-during-rendering divergence in the MiSTer sibling: `chrram-live` went from **32,861 of 61,440 differing pixels**, red for eight releases, to "All 61440 pixels match", and `chrram-fetch` from 21,941 to 18. Eleven prior variants had been measured correctly and the conclusion drawn from them was wrong — they swept what the `$2007` arm computes and never what it computes it from. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction** and were re-run anyway. The bitstream is re-cut at fitter seed 4 (+0.421 ns setup / +0.112 ns hold) and **no hardware has run it**. The board session is now **v2.9.2** and the hardware-verified core **v3.0.0**, after three audit lines ([ADR 0041](adr/0041-hardware-release-is-v3.0.0.md)). Built on **v2.6.22 "Rigging"** — the instruments for the board, built before the board. **No hardware has run any bitstream**; the session with the board (then planned as "Shakedown"; since ADR 0041, v2.9.2) is the hardware half and this is the non-hardware half of it, cut separately so that name keeps meaning what it says. AccuracyCoin now reads back from hardware as BYTES rather than as a photograph — a mirror ROM copies its result vector into the battery-backed save window, with a byte budget that proves the patch moved nothing and a simulation control that proves it changed no answer. The catalog turned out to have **149 rows and 144 results**: five `Power On State` rows share upstream's omit-sentinel, and counting them made the headline a function of *when the run was sampled*. **144/144 and nestest 0-diff are unchanged** — the count stopped depending on the sampling instant. And the `46199ae4` re-sync turned out to have been half-done: three goldens described a ROM no longer in the tree, which no gate could see because `rom_sha256` was written into every manifest and compared to nothing. Built on **v2.6.21 "Steward"** — the board arrives, and the core is not ready for it. Battery saves exist for the first time; the CHR-during-rendering gate the retrospective audit asked for is added and is **RED at 32,861 of 61,440 pixels**, with the obvious fix measured insufficient at 715 recovered; two gates existed that nothing ran; and the deploy loop the runbook prescribed was built, which found two defects in its own spec. The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.20 "Telltale"** — the counter had no reader, and two knobs turned out to be one decision. The FPGA core's OAM2 address counter was write-only for a release, so `$2004`'s post-fetch rest value was a hardcoded index 0; making it observable took the DUT to **AccuracyCoin 149 of 149** and the co-simulation ladder to **150 of 150, 0 failed**. Restoring the increment window v2.6.19 had narrowed then broke sprite rendering by six pixels, because the window and the deleted dot-257 latch are ONE decision with two self-consistent answers and only one of them is the hardware's. The shipped configuration restores the latch: window 256, the dot-257 freeze latch back and consumed by sprite fetch, and `$2004` reading the live counter. A CHR-RAM write gate was added. **v2.6.21 corrects this sentence: it did NOT close the coverage the audit named.** The audit named writes *while rendering is enabled*, and PROGRAM53 writes with `PPUMASK = 0` and renders afterwards. The real case is gated at v2.6.21 and is RED -- 32,861 of 61,440 pixels. The emulation core is unchanged, so **144/144 and nestest 0-diff hold by construction**. Built on **v2.6.19 "Accession"** — the DUT absorbs two releases of oracle behaviour, and the seed rule catches something for the first time. The FPGA core implements the OAM2 address counter it never had and stops acting on a `$2001` change that lands during dot 256 -- AccuracyCoin on the DUT **148 of 149**, the ladder **147 of 147**. The fitter pin is re-derived over ten seeds and stays at 3. (The contemporaneous claim that seed 1 stopped closing is withdrawn -- that sweep recompiled in place and was order-dependent; seed 1 closes, and seed 8 is the one that does not.) The emulation core is unchanged, so **AccuracyCoin 144/144 and nestest 0-diff hold by construction**. Built on **v2.6.18** (2026-09-12) — **"Errata"**, the recorded cause was wrong in three ways, and the last AccuracyCoin entry closes at 144/144 -- a `$2001` change during dot N must not act on dot N, at the dot-256 vertical increment and the dot-339 OAM2 reset. Built on **v2.6.17** (2026-09-11) — **"Terminus"**, the accuracy battery grows to 144 assigned tests and one of the new ones names the dots a write lands on -- this core is two dots early, measured twice on two independent `$2001` writes, and moving it is refuted by the gate this version wrote in advance, so the documented compensation stays and 143 of 144 ships. Built on **v2.6.16 "Interlock"** (2026-09-04) — the arbiter's numbers describe a stimulus, not the console -- and the console meets the CPU's deadline at zero margin, on every one of 240,303 requests, where the gate said it missed by four. Built on **v2.6.15 "Warrant"** (2026-09-04) — the claims v2.7.0 will make become checkable, and the instrument pays the oracle back. Built on **v2.6.14 "Docket"** (2026-09-03) — the submission checklist becomes auditable, and auditing it finds five boxes already true, two ticked on evidence that expired, and one that could never have been ticked honestly. v2.7.0 IS the submission, so the list in `to-dos/mister/contribution-checklist.md` decides whether the core is ready -- and it had 30 boxes, 16 unticked, and FOURTEEN OF THOSE SIXTEEN saying nothing at all about why. That ambiguity is the defect: an unticked box with no reason cannot be told apart from work outstanding, work blocked outside this repository, and WORK ALREADY DONE AND NEVER TICKED, and the third case occurred five times -- the provenance CI job, the SPDX sweep, the firewall statement, the AccuracyCoin vector and the preservation-value case were all true and all unticked, so the list reported the project as further from submission than it is by a fifth of its own length. ONE BOX COULD NEVER HAVE BEEN TICKED HONESTLY: it asked that `docs/provenance.md` state "that no NES core was ever opened", and that same document's section Do not self-certify forbids exactly that class of finished claim, so satisfying the box required writing the one sentence the provenance rules exist to prevent -- a wrong requirement rather than a missing tick, and only reading every item found it. RE-MEASURING THE TICKED HALF found two more, which is v2.6.9's lesson in a different document: `RustyNES.sdc` still said "there is exactly one core clock", true of v2.6.3 and false since v2.6.13, which added a 4x SDRAM clock and a phase-shifted pin clock that the shipped timing report names alongside it; and the `.qsf` entry said all 109 pin assignments come from `sys/sys.tcl`, where 109 is what that script supplies before stopping short of the I/O board, so a core must also source one of two 36-assignment variants -- 145 in total, from two scripts. THE ALARMING READING OF THE FIRST WAS CHECKED BEFORE IT WAS WRITTEN DOWN: it looks as though the framework's `set_clock_groups -exclusive` might be cutting the console-to-SDRAM crossing and leaving ADR 0039's safety argument unfalsifiable, and it is not -- exclusive cuts paths BETWEEN groups, all three outputs match one glob, and v2.6.13's own -24.769 ns measurement of that crossing is only observable because those paths are analysed. THE NAMING DIVERGENCE IS MEASURED RATHER THAN PARAPHRASED, from `Main_MiSTer/file_io.cpp` instead of the wiki: `get_display_name` searches for the literal underscore-two-zero and TRUNCATES THE DISPLAY NAME THERE, taking the rest as a datecode, and `DirentComp` groups by that truncated name -- so a version-named bitstream makes every release a SEPARATE CORE ENTRY, named for the version rather than the core, ordered alphabetically, which puts v2.6.9 after v2.6.13. The fix is one line and is NOT taken here, because version-naming is a maintainer decision with a stated rationale and reversing it is not an audit's call. THE TASK BOARD HAD THE SAME DEFECT AND ONE ROW WORSE: four delivered items never ticked, a hardware row still naming a version seven releases past, and an SDRAM row whose precondition -- "after a board exists" -- v2.6.13 simply did not follow, having accepted the controller against a behavioural model written from the datasheet instead; that is rung 7's own recorded lesson repeating one row below where it is written, since the blocker applied to hardware ACCEPTANCE rather than to building the thing. A CLAIM v2.6.13 SHIPPED IS RETRACTED: answering a review finding, I wrote that MMC1 was the sixth approved family and not yet implemented, and the cartridge decodes mapper 1 at three sites with a registered gate green since rung 7 opened -- asserted from memory inside a reply correcting somebody else's reading of the same line. The gate that keeps all this true asserts a SHAPE rather than a judgement and is demonstrated by five mutations, one of which proves the continuation-line folding load-bearing by producing nine false violations without it. The bitstream is BYTE-IDENTICAL to v2.6.13's, which is the point: the only sibling change is a comment, and an identical artifact demonstrates it. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.13 "Slack"** (2026-09-03) — the cartridge outgrows the die, and three consumers want the same bus. An SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge, all written from the AS4C32M16SB-7 datasheet revision 1.4 -- no third-party controller read, ADR 0037 applying. The budget the previous step worked to was a single figure read off the fetch structure and never measured; `nes_top`'s `CHR_LAT` sweep asks the console directly, and there are THREE answers: a background or sprite fetch tolerates 28 cycles and uses 17, the PPUDATA data port tolerates 8, and the CPU sampling at mc7 has 24 -- four cycles of every budget going to the console-domain crossing, which is not optional, because publishing a runtime modulo combinationally into an 11.64 ns domain costs -24.769 ns of setup. The PPUDATA port could never fit, half its budget being the crossing, so it leaves the shared bus entirely: `ppu2c02` gains `BUFFER_HANDSHAKE` and fills its read buffer through a port of its own on the arbiter, affordable because the CPU does not read that buffer until its next PPUDATA access. THAT FIX SHIPPED A DEFECT ONLY A BANKED CARTRIDGE COULD SEE -- the request carried the RAW fourteen bits the PPU presents, which is right by coincidence on NROM because the mapper's translation is the identity there, and reads BANK ZERO on everything else; `ppu-misc-2007-stress` passed off-die throughout while the two CNROM gates read a zero byte where the oracle reads one, one and two. The fix REMOVES the address rather than correcting it: `cart.sv` already publishes the translation for the fetch path, so the request carries none and there is one source of truth for where CHR lives instead of two that agree only on NROM. A DELETION WAS THEN REFUTED BY ONE CYCLE: with the address fixed, a control issuing at a flat +7 passed both gates, which read as the anti-contention deferral buying nothing, so it was removed -- and the deployed code issued at +6, and both gates failed again. The control and the code differed by a single cycle, which is the measurement of how thin the CPU's off-die deadline is and the concrete argument for scheduling the bus rather than arbitrating it. Two INFERRED LATCHES that Verilator cannot see: `ppu2c02` assigned two signals only under `BUFFER_HANDSHAKE`, so in the shipped on-die build the only assignment either reached was the reset branch, and a variable holding its previous value on every live path is a latch -- Quartus said so twice while the lint gate stayed green, and the comment beside them asserted the defect as a virtue ("outside BUFFER_HANDSHAKE neither signal ever moves", which is true and is exactly the condition). And a defect in the HARNESS: `USE_SDRAM` reaches Verilator as `-G` rather than as a file, so make ran ONE binary under both configurations' names and a log labelled on-die was the off-die build, reproducing the off-die failures exactly -- closed with a stamp-file prerequisite, demonstrated by mutation. THE OPEN ROW: accesses no longer auto-precharge, a hit costs 6 cycles against 10 for a miss, tRAS's 120 us MAXIMUM is respected by an early close in idle, and two defects came out of the rewrite -- re-entering idle with the request still asserted issued every access TWICE, and subtracting one from CAS the way the other waits are computed broke every read to all zeroes, CAS being "data appears at cycle N" rather than a command-to-command gap. Two MiSTer tickets close: `sdram_sz` is consumed VALIDITY BIT FIRST, so `absent` is deliberately not `!present` and a power-on all zeroes cannot read as "no board" (gated exhaustively over all 65,536 values), and `status_menumask` is computed rather than tied off, greying Reset whenever the console is already held in reset. OFF THE DIE THE CONSOLE PASSES 142 OF 142, every gate the on-die build passes, with better timing margin and 384 fewer M10K blocks -- and it still SHIPS ON the die, because an off-die core cannot run at all without the SDRAM add-on while rung 7's five mapper families fit on the die at 468 of 553 blocks. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.12 "Groundwork"** (2026-09-02) — the bitstream was an NROM-only console. Rung 7 landed five mapper families and 142 co-simulation gates verify them, and the layer that turns that RTL into a bitstream was never told: `rtl/emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND -- mapper 0 for EVERY cartridge, `prg_8k_count = 0` collapsing PRG to an 8 KiB window, and CHR forced to RAM. The declared 256 KiB PRG and 128 KiB CHR were implemented as 8 KiB each; connecting three wires takes block memory from 666,061 to 3,680,717 bits and timing still closes at all four corners. NOTHING COULD HAVE CAUGHT IT: simulation cannot, because `emu.sv` is not in the testbench file list and the harness drives those ports itself, so all 142 gates exercised a correctly-configured cartridge; and Quartus DID say so three times, in messages that cite an INSTANCE path rather than a file and are absent from the "0 errors, N warnings" tally, so the existing checker read 0 of 125. Two gates close that -- one fails on an unconnected pin of any module this repository declares, the other pins the warning SET rather than its count -- and both are demonstrated to fail by mutation. The `hps_io` tie-off audit that followed raised nine `T-MISTER-*` tickets, and annotating each with a blocker turned "none of these landed" into a measurement: not one is blocked on EFFORT, so the list is the rung-6 agenda rather than a backlog. `T-MISTER-SAVE` was attempted and refuted -- every save route terminates in `hps_io`, which no gate here instantiates. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Built on **v2.6.11 "Exposure"** (2026-09-02) — a picture is a gate the ladder did not have. All 141 co-simulation gates THEN IN THE SUITE were green (it ends this release at 142, the one it added) and TWO OF SIX commercial games rendered wrong -- a CHR-RAM write was taking the shared-pin composite address built for FETCHES instead of `v`, so the layout was right and the tiles were scrambled. The split is exactly CHR-ROM against CHR-RAM, which named the mechanism before any tracing, and a CONTROL says it is not v2.6.10's regression: the pre-M10K-fix RTL differs by the IDENTICAL 16,565 pixels, so the defect dates from the cartridge landing in v2.6.9. It was not UNREACHED -- the DUT asserts `chr_wr` 9,600 times in the Battletoads run -- it was UNCOMPARED: only THREE of the 141 gates compare a framebuffer, all three ship CHR-ROM, every other gate is CPU-side, and AccuracyCoin, the widest gate in the suite, is CHR-ROM too. The rung-7 gates' own comment says what they are for -- "these gates are about BANKING and nothing else" -- and it was accurate, and it was the whole coverage. Six commercial titles now render byte-identically to the oracle over all 61,440 pixels, published as a montage built by a script that REFUSES to publish a tile that differs from the oracle. The v2.6.10 bitstream carries the defect and a published version is immutable, so the corrected `.rbf` ships here. The same release finds EIGHT release leads describing v2.6.10 with v2.6.9's summary, and v2.6.9 gone from the lineage entirely, with `docs/STATUS.md` naming v2.6.10 under the codename "Abeyance" -- every existing check passing CORRECTLY, because they pin the version TOKEN and the token was right. Prose cannot be audited; an ORDERING can, so two gates are added and both are demonstrated to fail by mutation. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.10 "Inference"** (2026-09-01) — the cartridge meets the synthesiser. Five cartridge boards verified across 141 co-simulation gates had **never been through Quartus**, and Analysis & Synthesis refused the design: `chr` was written from TWO `always_ff` blocks, which cannot infer as one M10K, so 128 KB of CHR stayed in flip-flops -- **1,048,576 registers against roughly 166,000**. Simulation cannot ask this question: Verilator accepts both forms without complaint. It is v2.6.6's finding one layer out -- that release established that an M10K read is REGISTERED, this one that a correctly registered memory still will not infer with two writers. The fitter was also throttling itself under Auto Fit while the `.qsf` carried no optimisation assignments at all: at full effort **all six seeds close** where two had failed, so the effort settings move the whole distribution across zero and the seed only picks where in it you land -- and the project had been pinned to seed 4, the WORST of the six. Pinned at seed 3, +0.531 ns setup and +0.099 ns hold, byte-identical across two independent compiles. The bitstream v2.6.9 could not produce ships here. Built on **v2.6.9 "Abeyance"** (2026-08-31) — an exclusion hides improvement as well as regression, and both denied co-simulation streams close. The larger one was never the console: `apuconflict039` had been carried for seven releases as a declared diagnostic whose bus surface "carries nine divergences BY DESIGN", and the nine were a defect in the HARNESS -- on a cycle the CPU is held, the testbench built its record's bus data from a stale local rather than from the RTL's own latch. Taking it from the latch makes the stream IDENTICAL on all 357,361 overlapping cycles and all 88 checkpoints, and the local is now dead and deleted. The phrase "by design" is what stopped anyone re-checking it, because it reads as a property of the thing under test when it was a property of the instrument reading it. The other stream differs on EXACTLY ONE cycle, a documented and attributed OAM-corruption asymmetry -- and carrying that needed an instrument the suite did not have, because the PLANNED mechanism was refuted by its own mutation pass: an allowance by checkpoint index cannot work on a rolling hash, since one divergent cycle poisons every checkpoint after it, so allowing the first differing window simply moved the failure to the next one and allowing the rest is the all-or-nothing deny it was meant to replace. A per-cycle nine-field comparator with a scoped allowance costs ONE cycle of coverage instead of seventy-one checkpoints -- 357,360 of 357,361, against nothing at all before -- and it fails BOTH ways, so a DUT that improves cannot leave a stale allowance quietly hiding coverage; six mutations confirm it, including a cycle outside the compared window being REFUSED rather than allowed to match nothing. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction and were re-run anyway. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.8 "Arrears"** (2026-08-31) — the gates the previous release fixed and never widened. A deny list is an assertion about the THING UNDER TEST, so v2.6.7 changing both the DUT and the harness re-opened every exclusion -- and nothing re-measured one. FOUR of the six denied checkpoint streams were passing (`irqlat048`, `ppuvbl023`, `ppuvbl024`, `ppuvbl025`), and THREE of those were not run by the suite at all, so removing them from the deny list was INERT until they were also iterated. The nestest gate compared 265,000 cycles against a 5,062,688-cycle golden -- correct while caveat C6 was open, left behind the moment it closed -- and all 5,062,680 overlapping cycles now match, with the window DERIVED from the manifest. CAVEAT C4 CLOSES by demonstration: nestest raises `nmi_line` on 3,592 cycles and had no nine-field comparison at all, so wiring it makes an `o.nmi_line` mutation CAUGHT at checkpoint 28 of 1,237 while the bus gate passes the identical run. Suite 128 passed, 0 failed, 0 skipped; nine-field comparisons 51 -> 57. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no board is attached, confirmed by checking. Built on **v2.6.7 "Detent"** (2026-08-30) — the bitstream becomes a published release artifact and a one-cycle disagreement is pinned to the cycle it happens on. Every release from here ships a `.rbf` -- committed to the sibling's `releases/` and attached to the GitHub release on BOTH repositories -- reversing v2.6.6, which produced one and withheld it because no hardware had run it: the MiSTer distribution mechanism reads that path out of the REPOSITORY, so an empty `releases/` describes an undistributable core rather than a cautious one, and the caution moves from an absence into a disclosure naming what the ladder cannot reach by construction (the PPU gate compares the pre-palette index and the APU gate per-channel integer levels, so the palette, the video timing constants, the audio absolute level and its band-limiting all sit downstream of every gate). The build is REPRODUCIBLE and that is now measured rather than argued -- a from-scratch compile and an incremental one produce a byte-identical bitstream -- which is also how v2.6.6's published slack figures came to be WITHDRAWN: no corner of a clean rebuild reproduces them, the innocent explanation (a different timing corner) was checked first and refuted, and the correct pair is +0.108 ns setup and +0.042 ns hold at the binding corner. THE RELEASE GATE WAS READING THE WRONG CORNER -- Slow 100C is not the binding one on this design, so a bitstream failing at Slow -40C would have passed while the gate reported three times the real margin -- and the checker that reads it was wrong twice before mutation found both: it first extracted ZERO rows from both summary tables and reported that as "no negative slack", then, once fixed, reported FOURTEEN clocks from a report emptied of its data, having run past the closing rule into the next tables. Caveat C2 splits in two. The first residual was a TRACE OBSERVATION POINT -- the harness built its record after eight of a CPU cycle's twelve master clocks while the oracle reads at end-of-cycle, and the frame-counter interrupt asserts on the final edge -- and closing it took checkpoint comparisons from 3 to 11 and failures at one checkpoint from 30 to 3. The second is REAL, and the FIRST fix for it was REFUTED in a way that found the right one: moving all four effects of the write one cycle later to match the oracle drops blargg from 11/11 to 4 of 11, one of the seven being the ROM written to probe exactly that timing. Read as a measurement that PROVES the sequencer's maturation is correctly placed, which leaves only the other effects the same write schedules -- so separating ONLY the interrupt clear lands it at write+3, the documented cycle, while the frame counter's zeroing stays put. Checkpoint streams go from 11 identical to 52 of 58 and the suite from 87 to 122, with blargg still 11/11 and the bus still matching on all 2,680,239 overlapping cycles. The checkpoint gate is registered over 51 comparisons and STATES ITS BLIND SPOT: not one gated golden ever raises an NMI, so it cannot catch an nmi_line defect, and the attempt to close that hole found a FOURTH divergence cluster that three goldens had been hiding inside a "26 skipped" tally line. The emulation core is unchanged, so AccuracyCoin 141/141 and nestest 0-diff hold by construction. Rung 6 does NOT close -- no DE10-Nano and no SuperStation One are attached to this machine, confirmed by checking rather than assumed. Built on **v2.6.6 "Chassis"** (2026-08-29) — the console becomes a MiSTer core -- `sys/` vendored byte-identical against `Template_MiSTer@3ea1134c` (57 files, 0 content differences), a top level, a clock, a palette, video sync and an audio mixer, compiled by Quartus 17.0.2 into a Cyclone V bitstream with 0 errors, timing CLOSED, and a warning count taken from 111 to THREE -- all three inside the vendored framework or Quartus's own megafunction, none of them citing this project's RTL (worst setup +0.086 ns and worst hold +0.096 ns at the binding corners, seed 3 -- v2.6.7 also withdraws the +0.363/+0.245 v2.6.6 published, which a clean rebuild of that configuration reproduces at no corner, TNS 0.000 on every clock; the console's own clock +13.514 ns at an Fmax of 30.26 MHz against the 21.477272 MHz it needs). The emulation core is unchanged, so the co-simulation suite is an ACCEPTANCE CRITERION rather than a formality -- 87 passed, 0 failed -- and it earned that immediately, because the cartridge memories had to be rewritten: an M10K read is REGISTERED, so 40 KiB of asynchronously-read cartridge was 393,216 registers against roughly 166,000 available, under a comment claiming it inferred block RAM from the source style alone, and the README had stated the correct rule since v2.4.3. Two defects were found only by asking whether the outputs would work on real hardware: the audio would have been a full-scale DC rail, because the mixer output is unipolar with silence at zero and the framework maps unsigned silence to -32768, and two OSD scanline options did nothing because VGA_SL was tied to zero. And a convention enforced by a glob has no error message: sys_top.sdc groups the core clock by matching the hierarchical name pattern *|pll|pll_inst|altera_pll_i|*, so a differently-named PLL matched no group at all and every crossing to the framework audio, HDMI and HPS domains was analysed as synchronous -- -13.901 ns of slack and -422,601 ns of TNS on a design whose Fmax was already above requirement, with the compile succeeding and the Assembler reporting 0 errors and 0 warnings throughout. Built on **v2.6.5 "Muster"** (2026-08-29) — rung 5 closes — the AccuracyCoin status vector is identical entry for entry across all 146 entries, with 146 of 146 executed on both sides and none NotRun, where the same gate read 5 of 146 at the version's start. A muster is a roll call where every name is called AND answered, which is the two-clause acceptance exactly. Five PPU defects close the last six differing entries and four were invisible to every gate that existed when the version opened: the background shift registers' RELOAD and their shift clock need SEPARATE gates (with one shared gate the serial-in test was not merely failing but ARITHMETICALLY UNREACHABLE, since reload dots are absolute and the reload discards the low seven bits, so a serial-in one can never reach bit 7 on any alignment — and modelling both structures reproduces BOTH measured shifter values); the sprite X counters are NOT gated on rendering, which AccuracyCoin states outright and the ROM that states it passes either way, because it expects no hit at X=254 and a sprite shoved 18 dots right is also off the line; the PPUADDR second-write v-copy is DELAYED, as the wiki says inside the write sequence itself, swept 1 to 4 dots against a control at 8 and 12 that fails; and the pre-render line CLEARS secondary OAM, without which scanline 0 draws what scanline 239 left — no sprite can ever render on scanline 0, because OAM Y is one less than the display row, and a sprite-0 probe over the full 134 M-cycle battery found 24 hits with four of them there; and the octal latch holding across the read dot, which is verified by exactly ONE gate and was unverifiable until the v-copy delay landed, the two composing the hybrid address together and neither producing it alone. A DIAGNOSIS IS RETRACTED: the residual was read as a two-dot CPU/PPU alignment error from comparing dot spans across two instruments, and at the committed alignment the two consoles execute identical pc, bus_addr and bus_access for 1,695,131 cycles while a two-dot shift moves the first fork back to 593,228 and takes the differing share from 5.13% to 66.80%. The oracle changes on the default path, so AccuracyCoin 141/141 (RAM decoder) and nestest 0-diff are VERIFIED, not asserted. Built on **v2.6.4 "Rubric"** (2026-08-26) — OAM DMA lands and all nine AccuracyCoin disagreements close, every rule that closed the last three stated by the test ROM and by neither nesdev page — and then the gate that certified them is measured to cover 88 of 146 entries. The emulation core is unchanged. Built on **v2.6.3 "Mainspring"** (2026-08-25) — the DUT runs on one master clock, and four enables that were never enabling — plus AccuracyCoin end to end and a status vector that names its disagreements by test. The emulation core is unchanged. Built on **v2.6.2 "Witness"** (2026-08-24) — rung 4 closes: blargg APU battery 11/11 on the co-simulation DUT, six defects no self-written gate could see, and a suite that had been asserting nothing for five minor releases. The emulation core is unchanged. Built on **v2.6.1 "Interleave"** (2026-08-24) — the DMC and its DMA cycle steal in the MiSTer co-simulation DUT, cycle-exact on the bus. The emulation core is unchanged. Built on **v2.6.0 "Assay"** (2026-08-24) — the triangle, the noise channel and the sweep unit **in the MiSTer co-simulation DUT** — and an audit of how much of the APU was fitted to the oracle rather than derived from documentation. The emulation core is unchanged. Built on **v2.5.9 "Overture"** (2026-08-24) — rung 4 opens: the two pulse channels, the frame counter, and four ROM defects the stimulus measurement found first. Built on **v2.5.8 "Blanking"** (2026-08-24) — VBlank, NMI and the PPUSTATUS race close rung 3 — and both fixes were deletions. Built on **v2.5.7 "Collimation"** (2026-08-24) — sprite rendering closes exact — the phase was wrong by two dots, and every window was compensating. Built on **v2.5.6 "Vestige"** (2026-08-23) — Sprite evaluation closes: all 59,993 overlapping cycles match, nine of nine behavioural mutants caught and two proved inert (announced as seven of eight at the cut), and the fix is a byte index that outlives the walk that set it. Built on **v2.5.5 "Raster"** (2026-08-23) — the first full frame, and three blind spots in the stimulus that fed it. Built on **v2.5.4 "Escapement"** (2026-08-23) — the background fetch pipeline, and an access two dots early that five gates could not see. Built on **v2.5.3 "Hysteresis"** (2026-08-23) — toggling rendering takes effect three dots after the write, and four instruments to prove it. Built on **v2.5.2 "Dormant"** (2026-08-23) — the 2C02 register file, and a gate that passed while testing nothing. Built on **v2.5.1 "Retrace"** (2026-08-23) — the interrupt sweep closes rung 2, and a gate reported a pass it could not have earned. Built on **v2.5.0 "Rungwork"** (2026-08-23) — the 6502 rung, and the two gates it cannot reach. Built on **v2.4.9 "Plumbline II"** (2026-08-23) — the bus half of rung 2, and what it found the day it existed. Built on **v2.4.8 "Palimpsest"** (2026-08-23) — read-modify-write, and a gate that cannot see its own subject. Built on **v2.4.7 "Keystone"** (2026-08-23) — the stack closes, and a dead line proves itself dead. Built on **v2.4.6 "Abacus"** (2026-08-22) — the core learns arithmetic. Built on **v2.4.5 "Compass"** (2026-08-22) — the core reaches memory, and chooses. Built on **v2.4.4 "Ignition"** (2026-08-22) — the first real RTL -- the 6502's eight-cycle reset and the implied opcode group, matching the oracle on all seven CPU fields (29 > records, `RustyNES_MiSTer@7f092bd`). The oracle settled a question our own > prose could not: reset is EIGHT cycles, and `docs/cpu-6502.md` said both > seven and eight. The emulation core is untouched. diff --git a/ios/project.yml b/ios/project.yml index 217c2223..a9386c14 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -45,7 +45,7 @@ settings: # 2.0.8 through v2.9.9, since nothing moved it), and moved by # `scripts/release-automation/bump_release.py` from now on, starting with # the 3.0.0 cut. - MARKETING_VERSION: "3.1.0" + MARKETING_VERSION: "3.2.0" # The build (not marketing) number. Each TestFlight upload needs a UNIQUE one; # the fastlane `beta` lane overrides this with the CI run number at build time # (xcargs CURRENT_PROJECT_VERSION), so this checked-in "1" is only the local / diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index ae4aa3a6..bd6fd1e3 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -57,7 +57,7 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. ## Status -- **Current release:** **RustyNES v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** +- **Current release:** **RustyNES v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.** - **The line after v3.0.0 (drafted 2026-10-07):** [`plans/v3.1-to-v4.0-line-plan.md`](plans/v3.1-to-v4.0-line-plan.md) indexes v3.1.0 → v4.0.0, with one plan file per release and the maintainer's 29 decisions (D1-D29) tabled there. In order (D29 moved the hardware release to the end of v3.9.x the same day): - v3.0.1 "Mortar" (released 2026-10-07); - v3.1.0 "Bellwether" (2026-10-08): options, emphasis, the AccuracyCoin re-sync; @@ -73,7 +73,7 @@ v2.8.0 → v0.9.7; the synthesis itself = **v1.0.0**. - **Programme after v2.4.0 — the v2.4.1 → v2.5.0 "Fabric" line, and the v2.6–v2.9 programme behind it** *(since 2026-09-22 the programme ends at v3.0.0; ADR 0041)*. An **independently-written NES core in SystemVerilog for MiSTer FPGA and the Retro Remake SuperStation One, verified against RustyNES as an oracle.** Not a port, and it cannot be one: a MiSTer core is SystemVerilog compiled by Quartus 17.0.2 into a Cyclone V bitstream. The reference firewall therefore extends to HDL — `NES_MiSTer` and `fpganes` `rtl/` are **strict black boxes**, instantiable as opaque modules to compare *outputs*, never readable as source. **v2.5.0 is scoped to "the 6502 rung closes"** — the co-simulation harness plus a cycle-exact 6502, gated, **as planned**, on nestest 0-diff and per-cycle bus equality — of which **per-cycle bus equality was achieved and nestest 0-diff was not**: it stops at a `$2002` read where *both sides address it* and only the data differs, because the DUT has no PPU. That and the 5 M-cycle window are **reclassified as rung-3 acceptance criteria** rather than carried as v2.5.0 debt — because the arithmetic does not support more: a from-scratch cycle-accurate NES core is **7–13 months FTE** against a two-to-four-week window at demonstrated cadence. PPU, APU and MiSTer integration are **v2.6–v2.9**; stating that now is better than discovering it at v2.4.6. The design is **replay, not lockstep** (the determinism contract makes a pre-recorded trace exactly the trace a lockstep run would produce, and `Nes` has no per-cycle step to lockstep *with*), **no DPI-C** (it would put `` `ifdef SIMULATION `` guards into RTL that must also pass Quartus — the exact construct that lets a simulated netlist drift from the synthesised one), and **hash first, capture on divergence** (a 4200-frame AccuracyCoin run is ~7.5 GB of per-cycle CSV; 4096-cycle hash checkpoints are ~480 KB). **Two risks are accepted in writing:** the core may be **declined as a duplicate** — `NES_MiSTer` already scores 121/125 on AccuracyCoin, and *real Famicom AV hardware also scores ~121/125*, so there is no published accuracy headroom; and **the oracle can be wrong**, since 141/141 is not "matches silicon", so every rung is labelled by whether it has an **independent** oracle. Retro Remake is a planned fallback home, not a contingency. See ADR 0037, `docs/mister.md`, and [`plans/v2.5.0-fabric-plan.md`](plans/v2.5.0-fabric-plan.md). - **Programme after v2.5.0 — the v2.5.1 → v2.7.0 line: the rest of the console, and a contributable package.** The Fabric line is delivered and the 6502 rung is closed; this line builds the PPU, APU, mappers and MiSTer integration, and takes the core to a state worth submitting to MiSTer-devel. **Maintainer decisions, 2026-08-23:** hardware is **both boards eventually** — a DE10-Nano **plus the SDRAM add-on** (mandatory: the NES reads cartridge ROM directly and the onboard DDR3 is too slow) and a SuperStation One (128 MB integrated), with **one `.rbf` booting both** turning "SS1 runs MiSTer cores unmodified" from an inherited claim into a measured one; mappers are **the top six** — NROM, MMC1, UxROM, CNROM, MMC3, AxROM, ~90% of the licensed library by title count, explicitly **not** FDS, expansion audio, or the remaining ~168 families; and v2.7.0 is **scoped to what genuinely fits**, with the arithmetic stated up front (**rung 3 8–16 wk · rung 4 4–8 wk · rung 5 2–4 wk + a 4–12 wk tail · rung 6 2–4 wk · rung 7 4–8 wk = 20–40 weeks FTE** before the AccuracyCoin tail, across twenty release slots — **milestones, not dates**). **Rung 6 comes before rung 7 deliberately**: NROM at 327 Kb fits on-chip, so hardware bring-up needs no memory controller, and getting a board in the loop before writing the SDRAM controller de-risks the second largest technical item. Two v2.5.0 gates — **nestest 0-diff and the 5 M-cycle window** — are not carried as debt but reclassified as **rung-3 acceptance criteria**: both stop at a `$2002` read where *both sides address it* and only the data differs, because the DUT has no PPU. The contribution requirements were **fetched from the MiSTer-devel wiki rather than recalled**, and one line of it is the whole case for this programme: on AI-generated code the project asks for *"a minimum reasonable bar for readability and… evidence of quality and accuracy testing"* — the co-simulation apparatus **is** that evidence, and no incumbent core can show its equivalent. See [`plans/v2.7.0-mister-core-plan.md`](plans/v2.7.0-mister-core-plan.md), [`mister/`](mister/), and the four dated research files in `ref-docs/`. *Superseded 2026-09-22 by ADR 0041: the hardware-verified core and the contribution package are v3.0.0.* - **Historical detail — v2.2.4** (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%)**, nestest 0-diff, by construction. The work is a libretro-completeness audit + metadata correction: the core is confirmed to inherit every v2.2.3 change automatically (the fast-dot-path default, the `PPU_SNAPSHOT_VERSION` 8 / APU v4 save-state schema handled transparently by the dynamic `snapshot_core_into` sizing, the `Mapper::mix_audio` i32 widening, the Zapper model, and the `mNNN_` mapper rename), and both buildbot cross-ABIs the GitHub gate models — `x86_64-pc-windows-gnu` and `aarch64-linux-android` — build clean. `rustynes_libretro.info` (the metadata RetroArch's core downloader reads) is corrected: **`disk_control` `false` → `true`** (the FDS multi-side Disk Control interface has been wired since the buildbot recipe landed, but was advertised as absent — the real fix), `display_version` `v1.0.0` → `v2.2.4`, and the mapper count `168` → `172`. Also: the reviewer-tooling standardization onto the shared Antigravity template rides along (`scripts/agy-review.sh` + workflow). Documented libretro follow-up: **core options** (region / overscan / palette / accuracy toggles) remain unexposed (`core_options = "false"` is accurate, not stale) — a deliberate future enhancement, not a v2.2.4 gap. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.2.4]` + `docs/libretro/`. -- **Release line since v2.1.0:** the v2.1.x **"Fathom"** accuracy line (v2.1.0 → v2.1.10) → **v2.2.0 "Capstone"** (the milestone cut closing the "deepen the existing project" run) → **v2.2.1** (housekeeping) → **v2.2.2 "Conduit"** (build / distribution / CI-integrity) → **v2.2.3 "Datum"** (performance appraisal + the last two Holy Mapperel residuals closed) → **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut) → **v2.2.5 "Colophon"** → **v2.2.6 "Almanac"** → **v2.2.7 "Timbre II"** → **v2.2.8 "Aperture II"** → **v2.2.9 "Studio II"** → **v2.3.0 "Datum II"** → **v2.3.1 "Plumb Line"** → **v2.3.2 "Lucid"** → **v2.3.3 "Cadence"** → **v2.3.4 "Ledger"** → **v2.3.5 "Manifest"** → **v2.3.6 "Sounding"** → **v2.3.7 "Overtone"** → **v2.3.8 "Parallax"** → **v2.3.9 "Crucible"** → the **v2.4.x "Fabric"** co-simulation line (**v2.4.1 "Fabric"** → **v2.4.2 "Cairn"** → **v2.4.3 "Touchstone"** → **v2.4.4 "Ignition"** → **v2.4.5 "Compass"** → **v2.4.6 "Abacus"** → **v2.4.7 "Keystone"** → **v2.4.8 "Palimpsest"** → **v2.4.9 "Plumbline II"** → **v2.5.0 "Rungwork"** → **v2.5.1 "Retrace"** → **v2.5.2 "Dormant"** → **v2.5.3 "Hysteresis"** → **v2.5.4 "Escapement"** → **v2.5.5 "Raster"** → **v2.5.6 "Vestige"** → **v2.5.7 "Collimation"** → **v2.5.8 "Blanking"** → **v2.5.9 "Overture"** → **v2.6.0 "Assay"** → **v2.6.1 "Interleave"** → **v2.6.2 "Witness"** → **v2.6.3 "Mainspring"** → **v2.6.4 "Rubric"** → **v2.6.5 "Muster"** → **v2.6.6 "Chassis"** → **v2.6.7 "Detent"** (the bitstream becomes a published, reproducible release artifact and a one-cycle disagreement is attributed rather than fitted away) → **v2.6.8 "Arrears"** (the gates the previous release fixed and never widened: four of six denied checkpoint streams had been passing unnoticed, three of them not run by the suite at all, and nestest widens 19x and gains the nine-field comparison that closes the gate's stated `nmi_line` blind spot) → **v2.6.9 "Abeyance"** (an exclusion hides improvement as well as regression -- both denied co-simulation streams close, and the larger one was a defect in the HARNESS rather than the console, carried for seven releases behind the phrase "by design") → **v2.6.10 "Inference"** (the cartridge meets the synthesiser, and simulation could not have asked the question -- rung 7's five boards had never been through Quartus, and `chr` written from two separate `always_ff` blocks could not infer as an M10K, so 128 KB stayed in flip-flops: 1,048,576 registers against roughly 166,000. The fix is behaviourally invisible -- 141 gates unchanged -- and the release carries the bitstream the previous one could not produce) → **v2.6.11 "Exposure"** (a picture is a gate the ladder did not have -- all 141 co-simulation gates green and two of six commercial games rendering wrong, because only THREE of those gates compare a framebuffer and all three ship CHR-ROM, so a CHR-RAM write taking the shared-pin composite address built for fetches was reached 9,600 times and compared never)) -> **v2.6.12 "Groundwork"** (the bitstream was an NROM-only console -- `emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND and the fitted cartridge was mapper 0 with 8 KiB of PRG and 8 KiB of CHR against a declared 256 and 128; no gate could see it because `emu.sv` is not in the testbench file list, so all 142 gates exercised a correctly-configured cartridge, and Quartus said so three times in messages that cite an instance path rather than a file) -> **v2.6.13 "Slack"** (the cartridge outgrows the die -- an SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge written from the AS4C32M16SB-7 datasheet, and three consumers measured to three different budgets rather than the one figure the previous step assumed; the PPUDATA port leaves the shared bus through a handshake and that fix shipped a defect only a banked cartridge could see, the request carrying the raw PPU address where the mapper's translation was needed, so every banked-CHR board read bank zero; off the die the console passes 142 of 142 and it still ships on the die, because an off-die core cannot run without the add-on) -> **v2.6.14 "Docket"** (the submission checklist becomes auditable -- 30 boxes, 16 unticked and fourteen of those saying nothing about why, so an unticked box could not be told apart from work outstanding, work blocked elsewhere, and work already done and never ticked; the third case occurred five times. One box asked `docs/provenance.md` to state that no NES core was ever opened, which that document's own Do-not-self-certify section forbids, so it could only have been ticked by writing the sentence the provenance rules exist to prevent. Re-measuring the ticked half found two claims that had expired, the task board four delivered items never ticked and an SDRAM precondition the previous release did not follow, and its claim that MMC1 was unimplemented is retracted) → **v2.6.15 "Warrant"** (the claims the submission will make become checkable, and the instrument pays the oracle back — the `.rbf` name this core shipped would have distributed NOTHING, because `Distribution_MiSTer`'s builder skips any file whose stem does not end in `_` plus eight digits, so an accepted core would appear in the Cores table and ship nothing with no error anywhere; two of the four R1/R2 residuals ADR 0002 closed turn out never to have been IRQ-timing residuals, resting on an assertion blargg WITHDREW in the successor ROM; `sys/` verbatim, the `.qsf`'s single seed table and the bitstream name all become checks instead of claims; the nine rung-1 gates run in CI against a PINNED oracle commit, so the accuracy evidence stops being a document describing a check a reader cannot run; `cpu_interrupts_v2` lands on the DUT as the first INDEPENDENT interrupt oracle, five of five; and `T-ORACLE-001`'s opening claim is retracted — RustyNES does clock the MMC3 counter on the pre-render line, which `2-details` sub-test 8 has asserted all along) → **v2.6.16 "Interlock"** (the arbiter's numbers describe a stimulus, not the console: the gate's worst case issues a CHR and a PRG request on the same cycle and the running console never does — zero coincidences and a minimum separation of two over fifteen million SDRAM cycles — so both published figures are wrong about it in opposite directions, the CPU MEETING its deadline at 24 against 24 on all 240,303 requests where the gate said it missed by four, and the fetch reaching 23 against a derived 22 that a `CHR_LAT` sweep shows understates the real tolerance by at least nine cycles; the slot-scheduled arbiter this version was scoped around is therefore NOT built, and is still worth building because zero margin means one added cycle anywhere breaks the CPU) → **v2.6.17 "Terminus"** (a write lands where the cycle ENDS, and this core does not move to meet it. The AccuracyCoin and TriCNES oracles re-sync to upstream, and the battery grows 141 → 144 assigned tests across two new pages that RE-HOME eleven existing PPU tests, so a re-sync is not an append and a suite-keyed baseline has to be regenerated rather than extended. `OAM2Address` becomes a live counter through sprite fetch and the frozen fetch reads index 0 for every byte, closing two of the three new tests and carrying an approved SAVE-STATE EPOCH — `PPU_SNAPSHOT_VERSION` 8 → 9, so pre-v9 `.rns` states no longer load. The third names the dots its two `$2001` writes land on, which is how the core came to be measured TWO DOTS EARLY on both: a 6502 commits a write at phi2, the last of a CPU cycle's three PPU dots, and this core applies PPU register writes at M2-low, the first. The move was then MADE, measured against a first-difference control captured beforehand, and NOT ADOPTED — the write alone reads 141/144 and breaks the independent `ppu_vbl_nmi/10-even_odd_timing`, and the best combination found reads 142/144 against the 143/144 that ships, for a save-state epoch and six re-baselined goldens. `mask_for_skip_check` is PROVEN to be a compensation for the placement — under phi2 it needs one delay stage rather than two — and two of its three dependants are still un-re-derived, so adopting the mechanism now would replace a documented compensation with an undocumented one. `Misaligned OAM2 Address` was also found to be passing via TWO CANCELLING ERRORS, both since fixed independently. Ships one core fix nothing in the corpus could see — the misaligned-OAM `+4 & $FC` realignment, reached 114 times per battery run out of 56,953,944 out-of-range branches and pinned by a targeted test because no ROM's verdict moves — plus `terminus_control.rs` as a standing first-difference gate. The follow-up version the write-placement work had been scoped into is FOLDED here: that split existed because Terminus was to be a scheduler change with its own ADR and a full re-baseline, and the refutation means it is not — so v2.6.17 carries both). v2.6.18 "Errata" closes the last AccuracyCoin entry on top of it, and v2.6.19 "Accession" carried both into the FPGA core, v2.6.20 "Telltale" made the OAM2 counter observable, v2.6.21 "Steward" gave the core battery saves and gated CHR writes during rendering, v2.6.22 "Rigging" built the instruments a board would need before the board existed, v2.6.23 "Pulse" closed the CHR-during-rendering divergence by making the `$2007` access pulse the rendering pipeline's load instead of computing its own, and v2.7.0 "Palisade", the first of the ADR 0041 audit line, made a corrupt save state fail at restore rather than one tick later, stopped pulse 1 muting on the `$4001 = $08` sweep idiom, and gave the save-state fuzz target the reach to find five defects the audit had not, v2.7.1 "Keepsake" stopped six cartridge boards writing an empty battery save and moved every user file the frontend writes onto the atomic writer, v2.7.2 "Bankroll" gave MMC1, MMC5 and Namco 163 the banks their boards have and made `$6000-$7FFF` read open bus on the 205 board variants with nothing there, v2.7.3 "Hearth" made the desktop keep battery saves, closed three ways around the Lua sandbox, gated script HTTP destinations, and brought audio back after a device change, v2.7.4 "Pocket" kept the Android and iOS apps alive through an internal error, gave both battery saves, paused them and released audio when they leave the screen, and made their saves atomic, v2.7.5 "Tally" closed the core audit's twelve performance proposals (eleven by measurement, one adopted: the audio buffer keeps its capacity), deprecated 18 dead bus methods and `ApuBus`, and closed the core and frontend ledgers, v2.7.6 "Recount" measured alone the six deletions v2.7.5 had bounded only together (five zero; the sixth, never reached by the benchmarks before, bounds at 0.2%), turned three redundant fast-path stores into an assertion, and shipped the contributed libretro buildbot fixes and targets, v2.8.0 "Bulkhead" opened the v2.8.x libretro + RTL audit line: the libretro core unwinds and contains a panic at its own boundary, keeps save states the right size with a Zapper, withdraws its memory maps on unload, loads through the standard `retro_game_info` via a vendored binding, and the save state carries the internal data bus, v2.8.1 "Gasket" closed the libretro audit: Four Score, four described ports (the list now NULL-terminated, where RetroArch had read past it), the Zapper unplugged, unclipped expansion audio, UNIF, and a Makefile that honours its callers, v2.8.2 "Solder" corrected the MiSTer core's on-die RTL against the oracle and the wiki (MMC3 acknowledge, SNROM PRG-RAM, the triangle and noise reload drop, the `$2002` latch) and found the audit's MMC1 finding inverted: the emulator was the one ignoring a reset, and v2.8.3 "Rivet" released every reset on the clock that uses it, cut the CPU by about 4% with two exact rewrites the fit report confirmed, and made the ladder run from a fresh checkout (164 of its 165 gates, a claim v2.8.4 found overstated), and v2.8.4 "Tether" closed the v2.8.x line: it found every off-die read would have been sampled a clock late on hardware (the SDRAM model and controller shared the error, and only the new SDRAM timing constraints could see it) and fixed both, pinned both builds at fitter seed 5 after a sweep of each, and made the ladder run all 165 gates from a clean checkout, and v2.9.0 "Survey" opened the v2.9.x line by re-running all four audits (139 rows re-verified, none regressed, 39 new findings fixed or dispositioned), including a Power Cycle that erased the battery save and an off-die MiSTer build that hung under `bootcore=`, v2.9.1 "Hone" found that the A/B tool had been timing the old code on both sides and re-measured what it had rejected, made a Vs. cabinet save-state about 9x faster, moved the off-die build's CHR into its own SDRAM bank, and pinned both builds at fitter seed 2 with two byte-identical clean compiles each, v2.9.2 "Candidate" triaged a fifth, 32-finding audit of both repositories (16 fixed, each finding with its evidence in its ledger), made save states keep the cartridge RAM of twelve board families that dropped it, fixed the MiSTer CPU losing an NMI raised inside a DMA, and cut the release-candidate bitstream pair for the SuperStation One session, v2.9.3 "Handset" moved every dependency to its newest release, answered all 244 review threads left open on PRs #7-#97 and fixed the ten findings that still held (mapper 28 rebuilt, a header editor that writes only what you change), and prepared the mobile device run, which the maintainer then moved after v3.0.0 with the board session, v2.9.4 "Plumb" recorded v3.0.0 as the API major with a release-candidate core (ADR 0043), made CI run the feature-gated tests, fuzz targets and a coverage floor it had only linted or never had, and corrected the records against the code, v2.9.5 "Caliper" gave every open accuracy item an outcome: blargg's `apu_test` frame-counter coincidence, the composite 2C02's odd-frame scanline-0 sprite pixel, OAM DMA filling the PPU I/O latch and KS7032's `$6000` window fixed red first, 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11), v2.9.6 "Roster" added seventeen mapper families from their NESdev pages (174 → 191), promoted GTROM to Curated with a modelled SST39SF040 whose saves persist, corrected mapper 4's NES 2.0 submappers (MMC6, NEC, MC-ACC, T9552), and re-baselined the local commercial suites, which had drifted unread since about v2.0.0, v2.9.7 "Tandem" brought the desktop's features to the web and phones (battery saves in the browser, Vs. DualSystem on web and mobile, FDS and NSF on mobile), made the release binaries the full native build, and fixed a PPU A12 defect real games exposed: the PPU reported one pulse per scanline where the console makes eight, so Acclaim's MC-ACC, the J.Y. ASIC and mapper 91 had counted eight times slow, v2.9.8 "Vanguard" carried v3.0.0's planned breaking changes ahead of it (a save identity that ignores the header, older save states and movies refused, movies and netplay that record the machine, the ADR 0042 API removals), booted every one of 748 staged dumps and fixed the defects that turned up, and made the game database's corrections reach every platform, v2.9.9 "Ballast", the release candidate for v3.0.0, re-ran all four audits and fixed what they found, raised the MMC3 IRQ by the NESdev rule (T-ORACLE-001) and gave the MMC5 the chip's CHR set selection (which fixed *Uchuu Keibitai SDF*), made audio exact across a save state, moved the MiSTer core's oracle pin onto it with the APU and PPU parity that opened, and cut the release-candidate bitstream pair at seed 6 (a seed-8 pair was withdrawn when the RTL changed after it), v3.0.0 "Cornerstone", the API major, gathered every break since v2.x in one place, made movies and netplay carry a core timing epoch so a version that emulates differently is refused with a reason (ADR 0045), took the last struct-extensibility breaks, closed blargg's `4-scanline_timing` in the emulator and the MiSTer core (T-MMC3-BG-A12), and shipped that core as a release candidate, not hardware-verified, and v3.0.1 "Mortar" unbanked *Famicom Yarou Vol.1*'s CHR-RAM (raising the emulation epoch to 2), reached the MiSTer core's odd-frame A12 exception with a new test ROM that found a one-cycle defect, moved the toolchain and the libretro buildbot to Rust 1.99, answered every bot review left since PR #1, recorded the shared Bisqwit NTSC pass as derived and moved the TriCNES source out of the repository, and wrote the plan to v4.0.0 (`to-dos/plans/v3.1-to-v4.0-line-plan.md`), and v3.1.0 "Bellwether", the current release, re-synced AccuracyCoin to upstream `f5f41dc2` and fixed the two defects it found (raising the epoch to 3), made the CPU overclock and the sprite-limit option work and travel with movies and netplay, corrected PAL emphasis and the alternate MMC3, gave the Vs. cabinet rewind and run-ahead, and matched the MiSTer core to all of it, including a `$2006` timing rule it had never been tested against. AccuracyCoin held **141/141** through v2.6.16, read **143/144 (99.31%)** at v2.6.17, where the upstream re-sync grew the battery to 144 assigned tests and `Frozen OAM2 Increment` was the single named failure, and read **144/144 (100.00%)** from v2.6.18 — **a score v3.1.0 showed was overstated**: the old ROM's `Misaligned OAM behavior` fail path fell through to a pass, hiding a real failure in every one of those releases; on upstream `f5f41dc2` the core reads **146/146** with that failure fixed — and the number was never always *by construction*: v2.3.4, v2.3.7, v2.3.9, the rung-3 releases v2.5.4-v2.5.6 and v2.6.17 change the core, so for those it is **verified** rather than inherited, and saying which is which is the point. **Full per-release detail is in `CHANGELOG.md` and `docs/STATUS.md` (the single source of truth)** — the entries below (v2.1.0 "Fathom" was the prior anchor here; v2.0.8 → v2.0.1) are the older historical trail, retained rather than duplicated. +- **Release line since v2.1.0:** the v2.1.x **"Fathom"** accuracy line (v2.1.0 → v2.1.10) → **v2.2.0 "Capstone"** (the milestone cut closing the "deepen the existing project" run) → **v2.2.1** (housekeeping) → **v2.2.2 "Conduit"** (build / distribution / CI-integrity) → **v2.2.3 "Datum"** (performance appraisal + the last two Holy Mapperel residuals closed) → **v2.2.4 "Cartridge"** (the libretro/RetroArch distribution cut) → **v2.2.5 "Colophon"** → **v2.2.6 "Almanac"** → **v2.2.7 "Timbre II"** → **v2.2.8 "Aperture II"** → **v2.2.9 "Studio II"** → **v2.3.0 "Datum II"** → **v2.3.1 "Plumb Line"** → **v2.3.2 "Lucid"** → **v2.3.3 "Cadence"** → **v2.3.4 "Ledger"** → **v2.3.5 "Manifest"** → **v2.3.6 "Sounding"** → **v2.3.7 "Overtone"** → **v2.3.8 "Parallax"** → **v2.3.9 "Crucible"** → the **v2.4.x "Fabric"** co-simulation line (**v2.4.1 "Fabric"** → **v2.4.2 "Cairn"** → **v2.4.3 "Touchstone"** → **v2.4.4 "Ignition"** → **v2.4.5 "Compass"** → **v2.4.6 "Abacus"** → **v2.4.7 "Keystone"** → **v2.4.8 "Palimpsest"** → **v2.4.9 "Plumbline II"** → **v2.5.0 "Rungwork"** → **v2.5.1 "Retrace"** → **v2.5.2 "Dormant"** → **v2.5.3 "Hysteresis"** → **v2.5.4 "Escapement"** → **v2.5.5 "Raster"** → **v2.5.6 "Vestige"** → **v2.5.7 "Collimation"** → **v2.5.8 "Blanking"** → **v2.5.9 "Overture"** → **v2.6.0 "Assay"** → **v2.6.1 "Interleave"** → **v2.6.2 "Witness"** → **v2.6.3 "Mainspring"** → **v2.6.4 "Rubric"** → **v2.6.5 "Muster"** → **v2.6.6 "Chassis"** → **v2.6.7 "Detent"** (the bitstream becomes a published, reproducible release artifact and a one-cycle disagreement is attributed rather than fitted away) → **v2.6.8 "Arrears"** (the gates the previous release fixed and never widened: four of six denied checkpoint streams had been passing unnoticed, three of them not run by the suite at all, and nestest widens 19x and gains the nine-field comparison that closes the gate's stated `nmi_line` blind spot) → **v2.6.9 "Abeyance"** (an exclusion hides improvement as well as regression -- both denied co-simulation streams close, and the larger one was a defect in the HARNESS rather than the console, carried for seven releases behind the phrase "by design") → **v2.6.10 "Inference"** (the cartridge meets the synthesiser, and simulation could not have asked the question -- rung 7's five boards had never been through Quartus, and `chr` written from two separate `always_ff` blocks could not infer as an M10K, so 128 KB stayed in flip-flops: 1,048,576 registers against roughly 166,000. The fix is behaviourally invisible -- 141 gates unchanged -- and the release carries the bitstream the previous one could not produce) → **v2.6.11 "Exposure"** (a picture is a gate the ladder did not have -- all 141 co-simulation gates green and two of six commercial games rendering wrong, because only THREE of those gates compare a framebuffer and all three ship CHR-ROM, so a CHR-RAM write taking the shared-pin composite address built for fetches was reached 9,600 times and compared never)) -> **v2.6.12 "Groundwork"** (the bitstream was an NROM-only console -- `emu.sv` left `cart_mapper`, `cart_prg_16k_banks` and `cart_chr_8k_banks` unconnected, so Quartus tied all three to GND and the fitted cartridge was mapper 0 with 8 KiB of PRG and 8 KiB of CHR against a declared 256 and 128; no gate could see it because `emu.sv` is not in the testbench file list, so all 142 gates exercised a correctly-configured cartridge, and Quartus said so three times in messages that cite an instance path rather than a file) -> **v2.6.13 "Slack"** (the cartridge outgrows the die -- an SDR SDRAM controller, a behavioural part model, a four-way arbiter and a console bridge written from the AS4C32M16SB-7 datasheet, and three consumers measured to three different budgets rather than the one figure the previous step assumed; the PPUDATA port leaves the shared bus through a handshake and that fix shipped a defect only a banked cartridge could see, the request carrying the raw PPU address where the mapper's translation was needed, so every banked-CHR board read bank zero; off the die the console passes 142 of 142 and it still ships on the die, because an off-die core cannot run without the add-on) -> **v2.6.14 "Docket"** (the submission checklist becomes auditable -- 30 boxes, 16 unticked and fourteen of those saying nothing about why, so an unticked box could not be told apart from work outstanding, work blocked elsewhere, and work already done and never ticked; the third case occurred five times. One box asked `docs/provenance.md` to state that no NES core was ever opened, which that document's own Do-not-self-certify section forbids, so it could only have been ticked by writing the sentence the provenance rules exist to prevent. Re-measuring the ticked half found two claims that had expired, the task board four delivered items never ticked and an SDRAM precondition the previous release did not follow, and its claim that MMC1 was unimplemented is retracted) → **v2.6.15 "Warrant"** (the claims the submission will make become checkable, and the instrument pays the oracle back — the `.rbf` name this core shipped would have distributed NOTHING, because `Distribution_MiSTer`'s builder skips any file whose stem does not end in `_` plus eight digits, so an accepted core would appear in the Cores table and ship nothing with no error anywhere; two of the four R1/R2 residuals ADR 0002 closed turn out never to have been IRQ-timing residuals, resting on an assertion blargg WITHDREW in the successor ROM; `sys/` verbatim, the `.qsf`'s single seed table and the bitstream name all become checks instead of claims; the nine rung-1 gates run in CI against a PINNED oracle commit, so the accuracy evidence stops being a document describing a check a reader cannot run; `cpu_interrupts_v2` lands on the DUT as the first INDEPENDENT interrupt oracle, five of five; and `T-ORACLE-001`'s opening claim is retracted — RustyNES does clock the MMC3 counter on the pre-render line, which `2-details` sub-test 8 has asserted all along) → **v2.6.16 "Interlock"** (the arbiter's numbers describe a stimulus, not the console: the gate's worst case issues a CHR and a PRG request on the same cycle and the running console never does — zero coincidences and a minimum separation of two over fifteen million SDRAM cycles — so both published figures are wrong about it in opposite directions, the CPU MEETING its deadline at 24 against 24 on all 240,303 requests where the gate said it missed by four, and the fetch reaching 23 against a derived 22 that a `CHR_LAT` sweep shows understates the real tolerance by at least nine cycles; the slot-scheduled arbiter this version was scoped around is therefore NOT built, and is still worth building because zero margin means one added cycle anywhere breaks the CPU) → **v2.6.17 "Terminus"** (a write lands where the cycle ENDS, and this core does not move to meet it. The AccuracyCoin and TriCNES oracles re-sync to upstream, and the battery grows 141 → 144 assigned tests across two new pages that RE-HOME eleven existing PPU tests, so a re-sync is not an append and a suite-keyed baseline has to be regenerated rather than extended. `OAM2Address` becomes a live counter through sprite fetch and the frozen fetch reads index 0 for every byte, closing two of the three new tests and carrying an approved SAVE-STATE EPOCH — `PPU_SNAPSHOT_VERSION` 8 → 9, so pre-v9 `.rns` states no longer load. The third names the dots its two `$2001` writes land on, which is how the core came to be measured TWO DOTS EARLY on both: a 6502 commits a write at phi2, the last of a CPU cycle's three PPU dots, and this core applies PPU register writes at M2-low, the first. The move was then MADE, measured against a first-difference control captured beforehand, and NOT ADOPTED — the write alone reads 141/144 and breaks the independent `ppu_vbl_nmi/10-even_odd_timing`, and the best combination found reads 142/144 against the 143/144 that ships, for a save-state epoch and six re-baselined goldens. `mask_for_skip_check` is PROVEN to be a compensation for the placement — under phi2 it needs one delay stage rather than two — and two of its three dependants are still un-re-derived, so adopting the mechanism now would replace a documented compensation with an undocumented one. `Misaligned OAM2 Address` was also found to be passing via TWO CANCELLING ERRORS, both since fixed independently. Ships one core fix nothing in the corpus could see — the misaligned-OAM `+4 & $FC` realignment, reached 114 times per battery run out of 56,953,944 out-of-range branches and pinned by a targeted test because no ROM's verdict moves — plus `terminus_control.rs` as a standing first-difference gate. The follow-up version the write-placement work had been scoped into is FOLDED here: that split existed because Terminus was to be a scheduler change with its own ADR and a full re-baseline, and the refutation means it is not — so v2.6.17 carries both). v2.6.18 "Errata" closes the last AccuracyCoin entry on top of it, and v2.6.19 "Accession" carried both into the FPGA core, v2.6.20 "Telltale" made the OAM2 counter observable, v2.6.21 "Steward" gave the core battery saves and gated CHR writes during rendering, v2.6.22 "Rigging" built the instruments a board would need before the board existed, v2.6.23 "Pulse" closed the CHR-during-rendering divergence by making the `$2007` access pulse the rendering pipeline's load instead of computing its own, and v2.7.0 "Palisade", the first of the ADR 0041 audit line, made a corrupt save state fail at restore rather than one tick later, stopped pulse 1 muting on the `$4001 = $08` sweep idiom, and gave the save-state fuzz target the reach to find five defects the audit had not, v2.7.1 "Keepsake" stopped six cartridge boards writing an empty battery save and moved every user file the frontend writes onto the atomic writer, v2.7.2 "Bankroll" gave MMC1, MMC5 and Namco 163 the banks their boards have and made `$6000-$7FFF` read open bus on the 205 board variants with nothing there, v2.7.3 "Hearth" made the desktop keep battery saves, closed three ways around the Lua sandbox, gated script HTTP destinations, and brought audio back after a device change, v2.7.4 "Pocket" kept the Android and iOS apps alive through an internal error, gave both battery saves, paused them and released audio when they leave the screen, and made their saves atomic, v2.7.5 "Tally" closed the core audit's twelve performance proposals (eleven by measurement, one adopted: the audio buffer keeps its capacity), deprecated 18 dead bus methods and `ApuBus`, and closed the core and frontend ledgers, v2.7.6 "Recount" measured alone the six deletions v2.7.5 had bounded only together (five zero; the sixth, never reached by the benchmarks before, bounds at 0.2%), turned three redundant fast-path stores into an assertion, and shipped the contributed libretro buildbot fixes and targets, v2.8.0 "Bulkhead" opened the v2.8.x libretro + RTL audit line: the libretro core unwinds and contains a panic at its own boundary, keeps save states the right size with a Zapper, withdraws its memory maps on unload, loads through the standard `retro_game_info` via a vendored binding, and the save state carries the internal data bus, v2.8.1 "Gasket" closed the libretro audit: Four Score, four described ports (the list now NULL-terminated, where RetroArch had read past it), the Zapper unplugged, unclipped expansion audio, UNIF, and a Makefile that honours its callers, v2.8.2 "Solder" corrected the MiSTer core's on-die RTL against the oracle and the wiki (MMC3 acknowledge, SNROM PRG-RAM, the triangle and noise reload drop, the `$2002` latch) and found the audit's MMC1 finding inverted: the emulator was the one ignoring a reset, and v2.8.3 "Rivet" released every reset on the clock that uses it, cut the CPU by about 4% with two exact rewrites the fit report confirmed, and made the ladder run from a fresh checkout (164 of its 165 gates, a claim v2.8.4 found overstated), and v2.8.4 "Tether" closed the v2.8.x line: it found every off-die read would have been sampled a clock late on hardware (the SDRAM model and controller shared the error, and only the new SDRAM timing constraints could see it) and fixed both, pinned both builds at fitter seed 5 after a sweep of each, and made the ladder run all 165 gates from a clean checkout, and v2.9.0 "Survey" opened the v2.9.x line by re-running all four audits (139 rows re-verified, none regressed, 39 new findings fixed or dispositioned), including a Power Cycle that erased the battery save and an off-die MiSTer build that hung under `bootcore=`, v2.9.1 "Hone" found that the A/B tool had been timing the old code on both sides and re-measured what it had rejected, made a Vs. cabinet save-state about 9x faster, moved the off-die build's CHR into its own SDRAM bank, and pinned both builds at fitter seed 2 with two byte-identical clean compiles each, v2.9.2 "Candidate" triaged a fifth, 32-finding audit of both repositories (16 fixed, each finding with its evidence in its ledger), made save states keep the cartridge RAM of twelve board families that dropped it, fixed the MiSTer CPU losing an NMI raised inside a DMA, and cut the release-candidate bitstream pair for the SuperStation One session, v2.9.3 "Handset" moved every dependency to its newest release, answered all 244 review threads left open on PRs #7-#97 and fixed the ten findings that still held (mapper 28 rebuilt, a header editor that writes only what you change), and prepared the mobile device run, which the maintainer then moved after v3.0.0 with the board session, v2.9.4 "Plumb" recorded v3.0.0 as the API major with a release-candidate core (ADR 0043), made CI run the feature-gated tests, fuzz targets and a coverage floor it had only linted or never had, and corrected the records against the code, v2.9.5 "Caliper" gave every open accuracy item an outcome: blargg's `apu_test` frame-counter coincidence, the composite 2C02's odd-frame scanline-0 sprite pixel, OAM DMA filling the PPU I/O latch and KS7032's `$6000` window fixed red first, 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11), v2.9.6 "Roster" added seventeen mapper families from their NESdev pages (174 → 191), promoted GTROM to Curated with a modelled SST39SF040 whose saves persist, corrected mapper 4's NES 2.0 submappers (MMC6, NEC, MC-ACC, T9552), and re-baselined the local commercial suites, which had drifted unread since about v2.0.0, v2.9.7 "Tandem" brought the desktop's features to the web and phones (battery saves in the browser, Vs. DualSystem on web and mobile, FDS and NSF on mobile), made the release binaries the full native build, and fixed a PPU A12 defect real games exposed: the PPU reported one pulse per scanline where the console makes eight, so Acclaim's MC-ACC, the J.Y. ASIC and mapper 91 had counted eight times slow, v2.9.8 "Vanguard" carried v3.0.0's planned breaking changes ahead of it (a save identity that ignores the header, older save states and movies refused, movies and netplay that record the machine, the ADR 0042 API removals), booted every one of 748 staged dumps and fixed the defects that turned up, and made the game database's corrections reach every platform, v2.9.9 "Ballast", the release candidate for v3.0.0, re-ran all four audits and fixed what they found, raised the MMC3 IRQ by the NESdev rule (T-ORACLE-001) and gave the MMC5 the chip's CHR set selection (which fixed *Uchuu Keibitai SDF*), made audio exact across a save state, moved the MiSTer core's oracle pin onto it with the APU and PPU parity that opened, and cut the release-candidate bitstream pair at seed 6 (a seed-8 pair was withdrawn when the RTL changed after it), v3.0.0 "Cornerstone", the API major, gathered every break since v2.x in one place, made movies and netplay carry a core timing epoch so a version that emulates differently is refused with a reason (ADR 0045), took the last struct-extensibility breaks, closed blargg's `4-scanline_timing` in the emulator and the MiSTer core (T-MMC3-BG-A12), and shipped that core as a release candidate, not hardware-verified, and v3.0.1 "Mortar" unbanked *Famicom Yarou Vol.1*'s CHR-RAM (raising the emulation epoch to 2), reached the MiSTer core's odd-frame A12 exception with a new test ROM that found a one-cycle defect, moved the toolchain and the libretro buildbot to Rust 1.99, answered every bot review left since PR #1, recorded the shared Bisqwit NTSC pass as derived and moved the TriCNES source out of the repository, and wrote the plan to v4.0.0 (`to-dos/plans/v3.1-to-v4.0-line-plan.md`), and v3.1.0 "Bellwether" re-synced AccuracyCoin to upstream `f5f41dc2` and fixed the two defects it found (raising the epoch to 3), made the CPU overclock and the sprite-limit option work and travel with movies and netplay, corrected PAL emphasis and the alternate MMC3, gave the Vs. cabinet rewind and run-ahead, and matched the MiSTer core to all of it, including a `$2006` timing rule it had never been tested against, and v3.2.0 "Confluence", the current release, brought every v3.2.0 branch in both repositories into one release: 17 mapper families (191 to 208, with the Bandai Datach), the VRC4 IRQ mode read the right way round and the VRC2 and *Fire Hawk* mirroring fixes (raising the epoch to 4), an AI remediation branch audited change by change before anything was kept, netplay protocol 8, libretro states of about 16 KB, and sixteen more boards, the Four Score, the Arkanoid controller, Game Genie cheats and custom palettes on the MiSTer core. AccuracyCoin held **141/141** through v2.6.16, read **143/144 (99.31%)** at v2.6.17, where the upstream re-sync grew the battery to 144 assigned tests and `Frozen OAM2 Increment` was the single named failure, and read **144/144 (100.00%)** from v2.6.18 — **a score v3.1.0 showed was overstated**: the old ROM's `Misaligned OAM behavior` fail path fell through to a pass, hiding a real failure in every one of those releases; on upstream `f5f41dc2` the core reads **146/146** with that failure fixed — and the number was never always *by construction*: v2.3.4, v2.3.7, v2.3.9, the rung-3 releases v2.5.4-v2.5.6 and v2.6.17 change the core, so for those it is **verified** rather than inherited, and saying which is which is the point. **Full per-release detail is in `CHANGELOG.md` and `docs/STATUS.md` (the single source of truth)** — the entries below (v2.1.0 "Fathom" was the prior anchor here; v2.0.8 → v2.0.1) are the older historical trail, retained rather than duplicated. - **Historical detail — v2.0.8 "Harbor"** (2026-07-09) (the preceding release is v2.9.9 "Ballast"; see the release line above) — the eighth release of the **v2.0.x mobile-finalization train** and the **iOS release candidate** ("Harborlight"), the final release of the iOS finalization window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.7** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched). It stages the App Store scaffolding for v2.1.0: version-controlled **App Store Connect listing metadata** (`fastlane/metadata/ios/{en-US,es-ES}/`, mirroring the Android tree, files-only), a **dormant App Store `release` lane** in `fastlane/Fastfile` that stages the build + listing but **does not submit** (`submit_for_review: false`) and is **not** CI-wired (the interim channel stays **TestFlight**), and an **App-Review §4.7 self-audit** (no bundled/downloadable ROMs, ownership notice, searchable library, 4+ rating) in `docs/ios-v2.0.8-readiness.md`. Version bump (workspace `2.0.7 → 2.0.8`; iOS `MARKETING_VERSION → 2.0.8`). **No store submission** (that is v2.1.0); screenshots, real signing, the listing upload, and the App-Review submission are the **maintainer / v2.0.9 / v2.1.0** closeout. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.8]` + `docs/ios-v2.0.8-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. - **Earlier in the train:** **RustyNES v2.0.7 "Harbor"** (2026-07-09) — the seventh release of the **v2.0.x mobile-finalization train** and the **third iOS finalization release** ("Trim"), continuing the iOS window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.6** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched). It wires the **App Store submission floor** (Apple mandates the **iOS 26 SDK / Xcode 26** for every App Store Connect upload from **2026-04-28**, so the tag-gated iOS CI now selects the newest Xcode 26.x on the runner — a build-SDK pin, non-breaking fallback on older images), **reconciles the deployment target `iOS 15.0 → 17.0`** to match the code's real API floor (`NavigationStack` iOS 16 + `.topBarTrailing` iOS 17, unguarded at 12+ sites — the prior 15.0 was never buildable), and **re-audits `PrivacyInfo.xcprivacy`** against the v2.0.6 crash reporter (no new data type / required-reason API — local-only, backup-excluded, off by default). Version bump (workspace `2.0.6 → 2.0.7`; iOS `MARKETING_VERSION → 2.0.7`). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); on-device profiling + the Xcode-26 archive are a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.7]` + `docs/ios-v2.0.7-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. - **Earlier in the train:** **RustyNES v2.0.6 "Harbor"** (2026-07-09) — the sixth release of the **v2.0.x mobile-finalization train** and the **second iOS finalization release** ("Parity"), continuing the iOS window (**v2.0.5 → v2.0.8**). A **host / iOS-only** cut: the cycle-accurate core is **unchanged and byte-identical to v2.0.5** (AccuracyCoin still **141/141, 100.00%**; nestest 0-diff; `#![no_std]` chip stack untouched), so no accuracy / save-state / determinism number moves. It adds a **new opt-in, privacy-first crash-reporting surface** (off by default — the iOS analogue of the Android v1.8.8 `CrashReporter`, closing the v1.9.9 iOS-applicable deferral): **Settings → Diagnostics** installs an uncaught-`NSException` handler that writes **local** crash logs the user can view + copy in-app — **nothing is uploaded**, so the "Data Not Collected" privacy label is unchanged (EN + ES); the handler re-checks the live opt-in at crash time so opting out stops new logs immediately. It also records the **feature-parity re-verification** of the v1.9.x host features (Game Center, CloudKit save sync, MFi controllers, capture / PiP, accessibility) against the unchanged v2.0.0 bridge surface. Version bump (workspace `2.0.5 → 2.0.6`; iOS `MARKETING_VERSION → 2.0.6`). **TestFlight-only** (App Store + AltStore PAL deferred to v2.1.0); on-device crash-capture verification is a **maintainer / v2.0.9** step. See `docs/STATUS.md` (single source of truth) + `CHANGELOG.md` `[2.0.6]` + `docs/ios-v2.0.6-readiness.md` + `to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md`. @@ -1487,6 +1487,8 @@ and the sibling's integration. Slots are proposals for the maintainer. | `T-ROUND2-UNTRIAGED` | The 11 Round 2 findings the import never applied, CORE-R2-01 first (it broke determinism when tried; a red test before any change) | Round 2 ledger | v3.3.0 | | `T-MISTER-VRAM-M10K` | The 2 KiB nametable is 16,384 register bits; three read sites and a write need a port plan before it can move to block RAM without a latency change | RTL-R2-02 | a MiSTer F-phase release | | `T-MISTER-OFFDIE-CHRRAM` | Off-die, a `$2007` CHR-RAM read soon after a write returns `$00` in simulation (pre-existing; `mapper13cprom206`, `mapper119tqrom230` are XFAILs) | sibling `docs/HARDWARE_TESTING.md` | v3.3.0 | +| `T-PERF-MAPPER-READ-DRIFT` | MMC5 and FME-7 `cpu_read` are 4.7% and 6.1% slower than at `c922ed40` (two runs each) with neither function changed. Find the cause (disassembly, alignment) before believing any later mapper-dispatch A/B on those boards, and measure whether a real MMC5 or FME-7 frame moves | [`docs/performance.md`](../docs/performance.md), "v3.2.0 import" | v3.3.0 | +| `T-SURVEY-UNTICKETED` | Triage the 25 items of the line plan's oracle survey that no plan, ticket or CHANGELOG entry names (ACC-11/12/14, AV-01..03, CI-03/06, CR-04/05, DOC-10, LR-01/05/06/07, MAP-04/06, NET-05, PERF-01..03, PROV-01..04; PROV-01 shipped as `T-NTSC-PROVENANCE` under its ticket name): ticket each, or record why not. The survey lived only in session scratch until v3.2.0 | [`plans/research/v3.1-line-survey-oracle-backlog.md`](plans/research/v3.1-line-survey-oracle-backlog.md) | v3.3.0 | ## Open questions blocking planning From 7b9777809e28afb36e199237b2d8cd68b4dca41a Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 13:58:34 -0400 Subject: [PATCH 074/104] build(deps): rustls 0.23.46 for RUSTSEC-2026-0346 to 0349 The cut's local gate run (scripts/release-automation/run_gates.sh, the first full run of it) failed one gate, `cargo deny check`: four rustls advisories published since the branch last resolved, all against 0.23.45 and all fixed in 0.23.46. 0346 Tls12/13ClientSessionValue derived Debug, printing the TLS 1.2 master secret / TLS 1.3 resumption PSK (custom session stores that log them; built-in Debug output is redacted) 0347 buffered application data sent to the outer server after an ECH rejection 0348 Brotli certificate decompression can allocate up to 1 GiB before authentication 0349 an unauthenticated ServerHello can downgrade later connections to classical key exchange rustls reaches RustyNES through tungstenite (the netplay WebSocket signalling, rustynes-netplay -> frontend and mobile) and ureq. Nothing here implements a ClientSessionStore, so 0346 was not reachable through our code; the others are in the library's own handshake path. A semver-patch move: `cargo update -p rustls`, Cargo.lock only (2 lines). The cosim lockfile does not carry rustls; the fuzz lockfile is untracked. Verified: cargo deny check -> advisories ok, bans ok, licenses ok, sources ok; clippy -D warnings for the workspace, the frontend's `full` feature set and wasm32; rustynes-netplay tests 127 passed, 0 failed. The rest of the gate run had passed at the cut commit (32 of 33, ROM suites skipped; they last ran at 00c355a2 and nothing since touches emulation). CHANGELOG [3.2.0] Dependencies and the release notes say so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 1 + CHANGELOG.md | 5 +++++ Cargo.lock | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md index a4d445f6..d8e466d8 100644 --- a/.github/release-notes/v3.2.0.md +++ b/.github/release-notes/v3.2.0.md @@ -25,6 +25,7 @@ Every v3.2.0 branch, both repositories, brought into one release. It adds 17 map - **Libretro save states are about 16 KB instead of 260 KB**: the framebuffer is no longer stored, and a full-size state still loads. - **New save files are owner-only on Linux and macOS** (`0o600`). +- **rustls 0.23.46**, for four TLS advisories published during the release (RUSTSEC-2026-0346 to 0349), in the netplay WebSocket and HTTP clients. - **The APU mixes 2-6% faster with expansion audio and 2-3% faster without**, byte-identical, in two independent A/B runs. - **An MMC3 cartridge read is about 9% faster, and run-ahead on an MMC3 game about 5%**, from cached bank offsets; byte-identical, two independent runs. Whole-frame time and the PPU do not change measurably. MMC5 and Sunsoft FME-7 reads measure 4.7% and 6% slower although neither read function changed; the cause is not established (`docs/performance.md`, "v3.2.0 import"). diff --git a/CHANGELOG.md b/CHANGELOG.md index dac930c2..55ef304c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,11 @@ cycle-accurate core later replaced. **Gradle wrapper 9.8.1** (#602; distribution and wrapper-jar SHA-256 both checked against services.gradle.org) and the **Compose compiler plugin 2.4.21** (#603) for the Android app. +- **rustls 0.23.45 -> 0.23.46**, which fixes four advisories published + during this release (RUSTSEC-2026-0346 to 0349: TLS secrets in a session + value's `Debug`, ECH early data, a 1 GiB Brotli certificate allocation, a + ServerHello key-exchange downgrade). It reaches the netplay WebSocket and + HTTP clients through `tungstenite` and `ureq`. ### Records diff --git a/Cargo.lock b/Cargo.lock index 731ffe4c..386a54a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4237,9 +4237,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", From 228935d4f7e22046ce204b290bd6fb92a9400c02 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 16:41:17 -0400 Subject: [PATCH 075/104] fix(ci): mirror the zero-copy iOS render in the host typecheck stub #604's first CI run failed "fmt + clippy + rustdoc" at its scripts/ios-host-typecheck.sh step: error[E0425]: cannot find value `FRAME_LEN` in module `crate::gfx_metal` --> crates/rustynes-ios/src/ffi.rs:100 error[E0061]: this method takes 0 arguments but 1 argument was supplied --> crates/rustynes-ios/src/ffi.rs:109 gfx.render(src) The iOS FFI is #[cfg(target_os = "ios")], so the script compiles ffi.rs on the host against a STUB gfx_metal whose signatures must mirror the real module (its own comment says so). v3.2.0 adopted the import's zero-copy render: ffi.rs now checks `len != gfx_metal::FRAME_LEN` and passes the caller's slice to `render(fb)`, and the real MetalGfx dropped `frame_buf_mut()`. The stub still had the old three-call shape. It now mirrors the real API: FRAME_LEN (256 x 240 x 4), `render(&mut self, &[u8])`, no `frame_buf_mut`, and no owned buffer. Why the cut's local gate run missed it: scripts/release-automation/ run_gates.sh transcribed ci.yml's cargo invocations but not this script step. It is now a gate there too (`ios-host-typecheck`), with a comment saying why. The other script steps in ci.yml are the two bench-regression checks, which need a CI base ref and stay CI-only. Verified: scripts/ios-host-typecheck.sh passes locally ("audio_dsp, audio_ring, audio and ffi compile and pass on the host"; gfx_metal type-checks against wgpu 30); run_gates.sh --only ios passes; shellcheck clean. The failing direction is CI run 38629731283's log above. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/ios-host-typecheck.sh | 11 +++++++---- scripts/release-automation/run_gates.sh | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/ios-host-typecheck.sh b/scripts/ios-host-typecheck.sh index 5f53b79a..8bfe5e0e 100755 --- a/scripts/ios-host-typecheck.sh +++ b/scripts/ios-host-typecheck.sh @@ -69,14 +69,17 @@ pub mod audio; /// there that \`ffi.rs\` depends on must be mirrored here. pub mod gfx_metal { use core::ffi::c_void; - pub struct MetalGfx(Vec); + /// One 256x240 RGBA frame, as the real module defines it. + pub(crate) const FRAME_LEN: usize = 256 * 240 * 4; + pub struct MetalGfx; impl MetalGfx { - pub fn new(_v: *mut c_void, _w: u32, _h: u32) -> Result { Ok(Self(vec![0; 4])) } + pub fn new(_v: *mut c_void, _w: u32, _h: u32) -> Result { Ok(Self) } pub fn resize(&mut self, _w: u32, _h: u32) {} pub fn set_filter(&mut self, _f: u8, _p: [f32; 4]) {} - pub fn frame_buf_mut(&mut self) -> &mut [u8] { &mut self.0 } pub fn set_index_frame(&mut self, _b: &[u8], _p: u8) {} - pub fn render(&mut self) {} + // v3.2.0: the frame is passed in (zero-copy) rather than written into + // a \`frame_buf_mut()\` the renderer owned. + pub fn render(&mut self, _fb: &[u8]) {} pub fn render_hd(&mut self, _fb: &[u8], _w: u32, _h: u32) {} } } diff --git a/scripts/release-automation/run_gates.sh b/scripts/release-automation/run_gates.sh index 3570f961..1492687e 100755 --- a/scripts/release-automation/run_gates.sh +++ b/scripts/release-automation/run_gates.sh @@ -93,6 +93,11 @@ gate clippy-wasm-browser-cheevos cargo clippy -p rustynes-frontend --target wasm gate rustdoc env RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps gate rustdoc-cosim env RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --manifest-path "$cosim" gate no-std cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features +# The iOS FFI is `#[cfg(target_os = "ios")]`, so no cargo invocation above +# compiles it; this script type-checks it on the host against a stub renderer. +# Added after v3.2.0's first CI run, where it was the one gate this list +# lacked, and the one that failed (ffi.rs had moved to a zero-copy render). +gate ios-host-typecheck scripts/ios-host-typecheck.sh gate deny cargo deny check gate test-workspace cargo test --workspace gate test-cosim cargo test --manifest-path "$cosim" From 4c4c7e1e40af3b4b84458090154a1cb2850b1e80 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 16:41:20 -0400 Subject: [PATCH 076/104] fix(ci): give bindgen a versioned Android triple for NDK 30 #604's "libretro cross-compile (aarch64-linux-android)" failed before any Rust compiled: rust-libretro-sys build script: Unable to generate libretro.h bindings: .../ndk/30.0.16248370/.../sysroot/usr/include/sys/cdefs.h:365:2: error: Unversioned target triples are not supported! Nothing in v3.2.0 touched this job; main's last CI (2026-10-12) passed it. The runner image's $ANDROID_NDK_LATEST_HOME moved to NDK 30, whose headers refuse an unversioned triple, and bindgen hands clang plain `aarch64-linux-android`. BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android now adds --target=aarch64-linux-android26 ahead of the existing --sysroot: 26 is the Android app's minSdk and the `cargo ndk --platform 26` level, so the generated bindings see the same headers the shipped core uses. Not verified locally: this machine has no NDK. The job's next run on #604 is the test, and the commit is separate so it can be judged alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 492fc0d9..7506c906 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -892,10 +892,17 @@ jobs: # build script panics before a single Rust line is compiled. The NDK path # prefix matches `android.yml`'s (`$ANDROID_NDK_LATEST_HOME`, then # `toolchains/llvm/prebuilt/linux-x86_64/...`). + # + # The --target carries an API level since the runner image moved to NDK + # 30 (October 2026): its sys/cdefs.h fails with "Unversioned target + # triples are not supported!" for plain `aarch64-linux-android`, which + # is the triple bindgen hands clang. 26 is the Android app's minSdk and + # the `--platform 26` cargo-ndk builds with, so the bindings see the + # same headers the shipped core is built against. - name: Point bindgen at the NDK sysroot if: matrix.target == 'aarch64-linux-android' run: | - echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--sysroot=$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "$GITHUB_ENV" + echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android26 --sysroot=$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "$GITHUB_ENV" # Provision the aarch64 glibc headers for bindgen (see the matrix note). # `libc6-dev-arm64-cross` lands the aarch64 glibc headers under # `/usr/aarch64-linux-gnu/include`; `--sysroot` sends clang there instead of From 2d6cec60aeab80af66b883bf5067a025a85e389f Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 16:46:36 -0400 Subject: [PATCH 077/104] docs: the counts the cut missed, from the #606 review CodeRabbit's review of the docs slice (#606) found ten statements the v3.2.0 cut left describing v3.1.0. Each was re-checked against the tree before editing; the counts were re-derived (208 families: STATUS.md; 51 / 130 / 27: mappers.md and tier.rs; nine fuzz targets: fuzz/Cargo.toml). - AGENTS.md: "191 families ... (51 / 109 / 31)" -> 208 and 51 / 130 / 27, the chain extended with v3.2.0's 17. bump_release.py moves only the "current release" leads, so standing counts elsewhere are by hand. - OVERVIEW.md (two places) and SUPPORT.md: 191 -> 208 mapper families. - SECURITY.md: 3.2.x is the current line; 3.1.x becomes Partial, the wording 3.0.x already uses. - VERSION-PLAN.md: the released v3.2.0 row leaves "Planned next", and the slot sentence now reads "after v3.2.0". The history row keeps the plan link and the one "(current)" marker. - docs/mappers.md, mapper 230: the row said "Curated on the brief's D23 rule ... the integrator may prefer" BestEffort while its tier column (and tier.rs, ADR 0011's 2026-10-09 ruling) say BestEffort. The leftover is replaced by the ruling's reason. - docs/performance.md, v3.2.0 APU: "the same sign on every workload" was false; silent_frame measured +0.20% and -0.20%. Now "both active workloads", which the table supports. - docs/testing-strategy.md: "Eight cargo-fuzz harnesses" -> nine, as the same section's list and its "all nine" already said. - to-dos/plans/v3.2.0-plan.md: S2's "item 3 done" listed five of item 3's six families; 290's outcome (stays BestEffort) is recorded. - docs/SALVAGE_MANIFEST.md: the 2026-10-14 scope ("scripts and docs only") is the maintainer's words and is kept; a parenthesis says it was applied as "everything hand-written", which is why source snapshots and probe crates are in the 93 files. Three findings were not applied, with replies on the threads: two asked to edit the verbatim ecosystem survey (kept as authored; the maintained v3.9.0 plan already has the narrower wording), and one said fuzz/Cargo.toml has no movie_import (it does, lines 90-91). Verified: markdownlint; release_anchor, release_state_prose and contribution_checklist audits pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- AGENTS.md | 2 +- OVERVIEW.md | 4 ++-- SECURITY.md | 3 ++- SUPPORT.md | 2 +- VERSION-PLAN.md | 3 +-- docs/SALVAGE_MANIFEST.md | 3 ++- docs/mappers.md | 2 +- docs/performance.md | 2 +- docs/testing-strategy.md | 2 +- to-dos/plans/v3.2.0-plan.md | 2 +- 10 files changed, 13 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 231def8d..cd367658 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in p - **Native Android app** — the **v1.8.0 → v1.8.9 "Android"** train (`crates/rustynes-mobile` UniFFI bridge + `crates/rustynes-android` JNI/NDK host + a Jetpack Compose app, ADR 0024): full on-device emulation, multi-touch + P1–P4 hardware controllers, wgpu `SurfaceView` rendering + the shared WGSL shader stack, save-states / battery SRAM, Lua, RetroAchievements, direct-IP + CGNAT/TURN room-code netplay, a box-art ROM library, and platform polish (adaptive / foldable / TV, Material You, capture / PiP / home-screen widget). Distributed as **GitHub-Releases sideload**; a free store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0025's v2.3.0 date; the `foss`/`play` flavour split remains). - **Native iOS / iPadOS app** — the **v1.9.0 → v1.9.9 "iOS" TestFlight train** (`crates/rustynes-ios` Metal + CoreAudio shim reusing `rustynes-mobile` verbatim → UniFFI-generated Swift, ADR 0026): a native SwiftUI shell over wgpu→Metal, multi-touch + GameController, the shader stack, TAS / HD-pack / palettes / per-game DB, Lua + RetroAchievements, LAN + room-code netplay, CloudKit save-state sync, accessibility + EN/ES i18n + ReplayKit + Game Center, and the v1.9.9 creator tools (Cheats, a FOSS-gated read-only debugger, a touch TAStudio piano-roll, foreign movie import, a host audio-depth DSP). Ships to **TestFlight**; a free App Store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0027's v2.3.0 date). Mobile ROM loading was iNES / NES 2.0 only until v2.9.7, which adds FDS (host-supplied BIOS), NSF and the Vs. DualSystem cabinet to the bridge (the Swift half uncompiled; run-sheet rows T1-T12). Readiness record: `docs/ios-v1.9.9-readiness.md`. - **Native Libretro core** — `crates/rustynes-libretro` (builds the `rustynes_libretro` shared library — `.so` / `.dylib` / `.dll` by platform, per the crate `Makefile`) integrates RustyNES into RetroArch (RetroAchievements, dynamic audio sync, deterministic save-state / rollback). Docs in `docs/libretro/`, plan in `to-dos/libretro/`, reference in `ref-docs/RustyNES-Libretro_Core.md`; the crate `Makefile` cross-compiles natively and `docs/libretro/UPSTREAM_SYNC.md` covers the re-fork / upstream-info-file (libretro-super + libretro-docs) workflow. -- **Mapper breadth → 191 families** (168 at the v1.7.x tag → 172 → 174 with v2.3.4's 176/2, 154 and 243 → 191 with v2.9.6's 17 families, all from the NESdev wiki), Core / Curated / BestEffort behind the CI accuracy-honesty gate (51 / 109 / 31). Battery saves go through `save_data()`, which differs from `sram()` only on the self-flashable GTROM and UNROM 512 boards. +- **Mapper breadth → 208 families** (168 at the v1.7.x tag → 172 → 174 with v2.3.4's 176/2, 154 and 243 → 191 with v2.9.6's 17 families → 208 with v3.2.0's 17, all from the NESdev wiki), Core / Curated / BestEffort behind the CI accuracy-honesty gate (51 / 130 / 27). Battery saves go through `save_data()`, which differs from `sram()` only on the self-flashable GTROM and UNROM 512 boards. - **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. **The cross-version facts worth keeping here:** RustyNES has broken its formats by design three times. **v2.0.0 "Timebase"** (ADR 0003) bumped the `.rns` save-state and `.rnm` movie format epochs (ADR 0028), so a pre-v2.0.0 slot fails to load with a clear error rather than silently misreading stale bytes. **v2.9.8 "Vanguard"** carried v3.0.0's planned breaks ahead of it (ADR 0042 / 0043 amendments of 2026-10-01; the maintainer kept the v2.9.x number as preparation for v3.0.0). It made three changes: diff --git a/OVERVIEW.md b/OVERVIEW.md index faf1f214..753ad8cf 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -79,7 +79,7 @@ RustyNES uses **cycle-accurate** emulation rather than scanline-based shortcuts. 1. **Emulation enthusiasts** — reference-grade accuracy with a modern, themeable desktop UX and an in-app debugger. 2. **The TAS community** — frame-perfect deterministic `.rnm` movie record / playback / branching built directly on the determinism contract. 3. **Netplay users** — GGPO-style rollback netplay (2–4 players), native (UDP) and in the browser (WebRTC). -4. **Homebrew developers** — broad mapper coverage (191 families), FDS, an instruction/PPU/memory debugger, and an embeddable `no_std` core. +4. **Homebrew developers** — broad mapper coverage (208 families), FDS, an instruction/PPU/memory debugger, and an embeddable `no_std` core. 5. **Rust developers** — a clean, modular workspace and a reusable 6502 CPU crate. --- @@ -89,7 +89,7 @@ RustyNES uses **cycle-accurate** emulation rather than scanline-based shortcuts. | Area | What ships today | |------|----------------------| | **Accuracy** | One-clock scheduler (v2.0.0 "Timebase"), master-clock timebase, AccuracyCoin **146/146 (100.00%)** on the battery as re-synced to upstream `f5f41dc2` in v3.1.0 (the 144/144 before it hid a masked failure), `nestest` 0-diff | -| **Cartridges** | **191** mapper families incl. expansion audio (VRC6/VRC7-OPLL/Sunsoft 5B/N163/MMC5) | +| **Cartridges** | **208** mapper families incl. expansion audio (VRC6/VRC7-OPLL/Sunsoft 5B/N163/MMC5) | | **Platforms** | iNES / NES 2.0, Famicom Disk System (real-BIOS boot, read/write, multi-side), Vs. System / PlayChoice-10 RGB | | **Online** | Rollback netplay, UDP (native) + WebRTC (browser), 2–4 players | | **Achievements** | RetroAchievements (opt-in, native-only) — login, hardcore, toasts, badges | diff --git a/SECURITY.md b/SECURITY.md index 48b8308b..ed57e33c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,8 @@ Report against the latest release or `main`. | Version | Supported | Notes | | ------------- | --------- | ----- | | main | Yes | Where fixes land first | -| 3.1.x | Yes | The current line | +| 3.2.x | Yes | The current line | +| 3.1.x | Partial | Fixes are shipped forward into the current line, not backported | | 3.0.x | Partial | Fixes are shipped forward into the current line, not backported | | 2.9.x | Partial | Fixes are shipped forward into the current line, not backported | | 2.8.x | Partial | Fixes are shipped forward into the current line, not backported | diff --git a/SUPPORT.md b/SUPPORT.md index b79aef02..1fb40b22 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -116,7 +116,7 @@ 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 **191 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). +A: iNES and NES 2.0 ROM formats across **208 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]?** diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md index 99d02c1d..01341e24 100644 --- a/VERSION-PLAN.md +++ b/VERSION-PLAN.md @@ -64,7 +64,7 @@ released version; these are plans, and `to-dos/plans/` holds the detail. The line from v3.1.0 to v4.0.0 is indexed in [`v3.1-to-v4.0-line-plan.md`](to-dos/plans/v3.1-to-v4.0-line-plan.md), drafted -2026-10-07 with the maintainer's decisions D1-D29. Version numbers after v3.1.0 +2026-10-07 with the maintainer's decisions D1-D29. Version numbers after v3.2.0 are slots. The hardware release is the last of v3.9.x, after v3.9.0's RTL feature freeze and before v4.0.0 (D29); it takes a number only after its board session (D1), and folds into v4.0.0 if its fixes are large. (The first draft @@ -73,7 +73,6 @@ day.) | Version | Scope | Plan | |---------|-------|------| -| v3.2.0 | Mapper breadth by real titles, the dump corpus, KNOWN_BLANK triage, tier promotions. MiSTer F1: options and about ten cheap families, paddle, Four Score, cheats | [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md) | | v3.3.0 | Phi2 write placement and the sprite-0 stale shifter; wgpu 31 / egui 0.37. MiSTer F2: the SDRAM arbiter, DDR3, save states, rewind; the off-die build becomes the headline | [`v3.3.0-plan.md`](to-dos/plans/v3.3.0-plan.md) | | v3.4.0 | Hosted netplay and the browser RA proxy on Cloudflare, RA hardcore compliance, native 3-4 player netplay. MiSTer F3a: MMC2/4, FME-7/5B, VRC2/4, the Zapper | [`v3.4.0-plan.md`](to-dos/plans/v3.4.0-plan.md) | | v3.5.0 | The full movie input stream, Lua sockets and shared memory, desktop polish. MiSTer F3b: MMC5, N163, VRC6/7, Bandai FCG, expansion audio | [`v3.5.0-plan.md`](to-dos/plans/v3.5.0-plan.md) | diff --git a/docs/SALVAGE_MANIFEST.md b/docs/SALVAGE_MANIFEST.md index cb5aac75..1319b0df 100644 --- a/docs/SALVAGE_MANIFEST.md +++ b/docs/SALVAGE_MANIFEST.md @@ -237,7 +237,8 @@ trees listed below. Moved with a SHA-256 check before each move: 883 files, ## 2026-10-14 — v3.2.0 release cut, session `f63c2db0` (second pass) Scope chosen by the maintainer: **hand-written scripts and docs only** from this -session's agent scratchpad (`/tmp/claude-1000//f63c2db0-.../scratchpad`), +session's agent scratchpad (applied as everything hand-written, so the pre-edit +source snapshots and the two probe crates' sources are included) (`/tmp/claude-1000//f63c2db0-.../scratchpad`), and none of the `/tmp` test leftovers. The tool's own scope could not express that (its `--exclude` does not filter directory candidates, and inside the scratch tree it ranks logs as strong), so the list was built by hand from file type, as on diff --git a/docs/mappers.md b/docs/mappers.md index 7a5540d6..40286ec6 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -975,7 +975,7 @@ committed screenshot) and pinned by a byte-identity boot snapshot in | 8 | — | FFE GNROM-mode extraction (`INES_Mapper_008`) | Curated | as 6 | "A synonym of Mapper 6 submapper 4": the same card in latch mode 4 (32 KiB PRG `D5-D4`, 8 KiB CHR `D1-D0`, CHR write-protected). The two *Doraemon World 3* hack images in `mapper-072-Jaleco72/`, whose masked dirty header is mapper 8, now boot and leave `external_coverage`'s `UNSUPPORTED` list. | | 17 | 0-3 | FFE Super Magic Card extraction (`INES_Mapper_017`) | Curated | as 6 | The same card starting in 4M mode with `$4504-$4507` on the image's last four 8 KiB banks and 1 KiB CHR enabled (`$4500 = $47`). The submapper is the trainer's load address (`$7000`, `$5D00`, `$5E00`, `$5F00`), and a power-on reset jumps to it instead of the vector. `$4510-$4517` start at 0-7 (the page gives no value). | | 235 | — | "Golden Game" 150-in-1 / 260-in-1 (`INES_Mapper_235`) | Curated | — | `m235_golden_game.rs`. Address latch `1xMP RNBB xxxA AAAA`: 32 KiB page A within the 1 MiB socket B; R = 1 maps half P of it twice; M picks CIRAM A10 from PPU A11 / A10 (H / V) and N forces page A (1scA), per the page's 74'153 description. RESET and power-on clear the register ("All eleven bits ... cleared on reset"). Sockets: a 4 MiB image fills all four; a 2 MiB image is sockets 0 and 2 ("00b = ROM 0, 10b = ROM 1"); 1 MiB or less is socket 0; an empty socket is open bus. The GoodNES `260-in-1 [p1][!]` header has iNES byte 4 = 0 for its 4 MiB, which iNES cannot express, so `parse` reads no PRG; it is staged with a NES 2.0 header (PRG MSB 1) and its body unchanged. | -| 230 | — | 22-in-1, reset-switched (`INES_Mapper_230`, Disch's notes) | BestEffort (the power-on mode and the reset behaviour are the model's choices; `docs/compatibility.md`) | — | `m230_reset_multicart.rs`. Chip 0 (first 128 KiB, *Contra*): `[.... .PPP]` 16 KiB at `$8000`, page 7 fixed at `$C000`, always vertical. Chip 1 (next 512 KiB): `[.MOP PPPP]`, O = 0 one 32 KiB window at P >> 1, O = 1 page P twice, M = 0 horizontal. The console RESET (`Mapper::reset`) toggles the chip and clears the register. The notes leave the power-on mode and the register across a toggle open: this board powers on in *Contra* and clears the register, and the dump behaves that way (title at power-on, menu after RESET). Curated on the brief's D23 rule although the page is Disch's notes only; the integrator may prefer the v2.9.6 rule that makes notes-only pages BestEffort. | +| 230 | — | 22-in-1, reset-switched (`INES_Mapper_230`, Disch's notes) | BestEffort (the power-on mode and the reset behaviour are the model's choices; `docs/compatibility.md`) | — | `m230_reset_multicart.rs`. Chip 0 (first 128 KiB, *Contra*): `[.... .PPP]` 16 KiB at `$8000`, page 7 fixed at `$C000`, always vertical. Chip 1 (next 512 KiB): `[.MOP PPPP]`, O = 0 one 32 KiB window at P >> 1, O = 1 page P twice, M = 0 horizontal. The console RESET (`Mapper::reset`) toggles the chip and clears the register. The notes leave the power-on mode and the register across a toggle open: this board powers on in *Contra* and clears the register, and the dump behaves that way (title at power-on, menu after RESET). BestEffort under D23 (ADR 0011, maintainer 2026-10-09): the notes do not state the power-on mode or what RESET does to the register, and the model's choices were never tested against an alternative. | **VRC4 IRQ mode bit, corrected (v3.2.0).** `m021_vrc4.rs` (mappers 21, 23, 25) read bit 2 of the IRQ control register the wrong way round: `VRC_IRQ` diff --git a/docs/performance.md b/docs/performance.md index 14d5a180..b6976cab 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1183,7 +1183,7 @@ so it is not reshaped again. reached through one call site with `BlipBuf::sanitize` choosing the value (so `add_sample(v)` is literally `add_sample_bounded(sanitize(v))`, byte-identical), and the filter split. Rows 3 and 4 are two independent runs with the same sign -on every workload and drift below the effect: `active_frame` -1.9% / -2.7%, +on both active workloads and drift below the effect: `active_frame` -1.9% / -2.7%, `active_frame_with_external` -2.9% / -6.1%, `silent_frame` unchanged. Which of the two kept changes earns which part was not separated. Byte identity: the APU suite (174 tests) passes; the full suite is re-run at the cut (3,659 passed, 0 diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index f1c0b6ed..467bbcb8 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -180,7 +180,7 @@ commit is the missing piece that would automate them. ## Layer 5 — fuzz testing -Eight `cargo-fuzz` harnesses under `fuzz/fuzz_targets/` (a standalone crate, +Nine `cargo-fuzz` harnesses under `fuzz/fuzz_targets/` (a standalone crate, nightly-only because libFuzzer needs it): - **`cartridge_parser`**: arbitrary `&[u8]` → `parse()`. Must not panic; errors typed. diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index 290178d3..e9d87581 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -108,7 +108,7 @@ until the hardware release shipped.) The codename is asked at the cut. | Sprint | Deliverable | Gate | Status | | --- | --- | --- | --- | | 3.2-S1 | Items 1 and 4 (oracle families and the corpus) | rows 1, 4 | — | -| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 done (KNOWN_BLANK 52 -> 41, every remaining entry triaged or previously documented) | +| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26); 290 stays BestEffort (its boot fix is recorded above; its capture's top band is unexplained); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 done (KNOWN_BLANK 52 -> 41, every remaining entry triaged or previously documented) | | 3.2-S3 | Item 5, the sibling options, first | row 5 | — | | 3.2-S4 | Items 6 and 7, the sibling mappers | rows 6, 7 | — | | 3.2-S5 | Item 8, then the seed sweep and the release cut | row 8; the sweep; both ladders | — | From 10d0b027d7eeaf4fb495a8c32ba92a5a45253fc6 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:00:20 -0400 Subject: [PATCH 078/104] fix(mappers)!: Bandai FCG save states carry the EEPROM's protocol state CodeRabbit on #604: a Bandai FCG save state (mappers 16, 153, 157, 159) stored each serial EEPROM's CONTENTS and the $800D line latch, but not the chip's protocol state: the two sampled line levels, the current and next mode, the device-select byte, the word address, the shift register, the bit counter and the output bit. A restore left those at their live values. Rewind, run-ahead and netplay rollback restore at arbitrary cycles, and a game talking to its save chip spans many frames, so a restore mid-transaction resumed a different transaction than the one saved. v3.2.0's 24C02 fix (`keeps_word_address`) made the word address live BETWEEN transactions too. Red first. `a_state_saved_mid_read_restores_the_transaction` starts an X24C01 read of $05 (holding $AB), saves after the address ACK, restores into a fresh board and reads on both: before the fix the restored board read $FF where the original read $AB. `Eeprom::save_lines` writes the nine bytes after the contents, in the main blob and in the Datach tail after its external chip. `check_lines` validates them (mode tag 0-6, counter <= 8, levels <= 1) and `load_state` now validates EVERYTHING before restoring anything: the protocol bytes, and the mirroring byte, which used to return Err only after the Datach tail, PRG bank and CHR banks had been restored (CodeRabbit on #607, outside the diff). `I2cMode` has explicit tags so reordering the enum cannot move the format. BREAKING (save states): `SAVE_STATE_VERSION` 2 -> 3, so a v3.1.x save state of any Bandai FCG game is refused with UnsupportedVersion. Other boards are unaffected; movies and netplay are already refused by epoch 4. Recorded in the CHANGELOG Breaking section and the release notes (the maintainer's standing rule prefers the correct format and records every break). Straight-run output is unchanged, so no golden moves. Tests: the red test; `a_corrupt_state_is_refused_before_anything_changes` (unknown mode, counter 9, SCL level 2, mirroring 9, each refused with the board unchanged), mutation-checked by removing the protocol check, which fails it. 32 m016 tests pass; mapper crate 1,085 tests pass. The file's Mesen2 `// Provenance:` header is untouched: the new code is RustyNES's own serialisation of those fields. A later comment saying the 24C02 behaviour came "not from any emulator" now says only what it was written from (a negative self-attestation, which the provenance guardrails ask us not to make). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../rustynes-mappers/src/m016_bandai_fcg.rs | 211 ++++++++++++++++-- 1 file changed, 196 insertions(+), 15 deletions(-) diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs index 36a33ae5..fe0eafd3 100644 --- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs +++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs @@ -100,7 +100,12 @@ const NAMETABLE_SIZE_U16: u16 = 0x0400; /// v2 (v2.9.6) appends mapper 153's outer bank, WRAM enable and WRAM. Since /// v2.9.8 (ADR 0042) a v1 blob is refused; it used to load on the variants /// that have neither. -const SAVE_STATE_VERSION: u8 = 2; +/// +/// v3 (v3.2.0) adds each EEPROM's protocol state ([`Eeprom::save_lines`], +/// nine bytes after its contents), so a state taken mid-transaction resumes +/// it. A v2 blob is refused: on mappers 16 (with an EEPROM), 157 and 159, a +/// v3.1.x save state does not load. +const SAVE_STATE_VERSION: u8 = 3; /// Mapper 153's WRAM. const WRAM_153: usize = 0x2000; @@ -213,7 +218,7 @@ struct Eeprom { /// unit's EEPROM and stopped on "no free block" at every boot; keeping it, /// the game reaches its title (measured 2026-10-08). Then applied to /// mapper 16's 24C02 as well, since it is the chip's behaviour, not the - /// board's. Written from that protocol description, not from any emulator. + /// board's. Written from that protocol description. /// Off for the X24C01, which takes its word address after every START, so /// the clearing is unobservable there. keeps_word_address: bool, @@ -230,6 +235,35 @@ enum I2cMode { WaitAck, } +impl I2cMode { + /// The save-state tag. Fixed numbers, not `as u8`, so reordering the enum + /// cannot silently change the format. + const fn to_byte(self) -> u8 { + match self { + Self::Idle => 0, + Self::ChipAddress => 1, + Self::Address => 2, + Self::Read => 3, + Self::Write => 4, + Self::SendAck => 5, + Self::WaitAck => 6, + } + } + + const fn from_byte(b: u8) -> Option { + Some(match b { + 0 => Self::Idle, + 1 => Self::ChipAddress, + 2 => Self::Address, + 3 => Self::Read, + 4 => Self::Write, + 5 => Self::SendAck, + 6 => Self::WaitAck, + _ => return None, + }) + } +} + impl Eeprom { fn new(bytes: usize, is_x24c01: bool) -> Self { Self { @@ -248,6 +282,64 @@ impl Eeprom { } } + /// Bytes [`Self::save_lines`] writes: the two sampled line levels, both + /// protocol modes, and the five transaction registers. + const LINES_LEN: usize = 9; + + /// The chip's PROTOCOL state, for a save state (v3.2.0, version 3). The + /// contents alone are not enough: a state taken in the middle of a + /// transaction must resume it, and since 24C02 random reads keep the word + /// address across a START (`keeps_word_address`), `addr` is live between + /// transactions too. Before version 3 a restore left all of this at its + /// live values, so rollback and run-ahead diverged from a straight run + /// (`CodeRabbit` on #604). This is RustyNES's own serialisation of the + /// fields above; nothing in it comes from a reference emulator. + fn save_lines(&self, out: &mut Vec) { + out.extend_from_slice(&[ + self.last_scl, + self.last_sda, + self.mode.to_byte(), + self.next_mode.to_byte(), + self.chip_addr, + self.addr, + self.data, + self.counter, + self.output, + ]); + } + + /// Check a [`Self::save_lines`] record without changing anything, so a + /// caller can validate every part of a state before restoring any of it. + fn check_lines(b: &[u8]) -> Result<(), MapperError> { + let bad = |what: &str| Err(MapperError::Invalid(format!("EEPROM state: {what}"))); + if b.len() != Self::LINES_LEN { + return bad("wrong length"); + } + if b[0] > 1 || b[1] > 1 || b[8] > 1 { + return bad("a line level above 1"); + } + if I2cMode::from_byte(b[2]).is_none() || I2cMode::from_byte(b[3]).is_none() { + return bad("unknown protocol mode"); + } + if b[7] > 8 { + return bad("bit counter above 8"); + } + Ok(()) + } + + /// Restore a record [`Self::check_lines`] accepted. + fn load_lines(&mut self, b: &[u8]) { + self.last_scl = b[0]; + self.last_sda = b[1]; + self.mode = I2cMode::from_byte(b[2]).unwrap_or(I2cMode::Idle); + self.next_mode = I2cMode::from_byte(b[3]).unwrap_or(I2cMode::Idle); + self.chip_addr = b[4]; + self.addr = b[5]; + self.data = b[6]; + self.counter = b[7]; + self.output = b[8]; + } + fn addr_mask(&self) -> u8 { if self.is_x24c01 { 0x7F } else { 0xFF } } @@ -594,6 +686,7 @@ impl BandaiFcg { out.push(u8::from(self.ext_scl)); if let Some(ext) = self.ext_eeprom.as_ref() { out.extend_from_slice(&ext.mem); + ext.save_lines(out); } self.barcode.save(out); } @@ -601,7 +694,10 @@ impl BandaiFcg { /// Restore [`Self::save_datach_tail`]. Validates the whole tail before /// changing anything. fn load_datach_tail(&mut self, tail: &[u8]) -> Result<(), MapperError> { - let ext_len = self.ext_eeprom.as_ref().map_or(0, |e| e.mem.len()); + let ext_len = self + .ext_eeprom + .as_ref() + .map_or(0, |e| e.mem.len() + Eeprom::LINES_LEN); let fixed = 1 + ext_len; let short = || MapperError::Invalid(format!("Datach state tail of {} bytes", tail.len())); if tail.len() < fixed { @@ -612,9 +708,14 @@ impl BandaiFcg { if fixed + used != tail.len() { return Err(short()); } + if let Some(ext) = self.ext_eeprom.as_ref() { + Eeprom::check_lines(&tail[1 + ext.mem.len()..fixed])?; + } self.ext_scl = tail[0] != 0; if let Some(ext) = self.ext_eeprom.as_mut() { - ext.mem.copy_from_slice(&tail[1..fixed]); + let n = ext.mem.len(); + ext.mem.copy_from_slice(&tail[1..=n]); + ext.load_lines(&tail[1 + n..fixed]); } self.barcode = reader; Ok(()) @@ -949,7 +1050,10 @@ impl Mapper for BandaiFcg { } fn save_state(&self) -> Vec { - let ee_len = self.eeprom.as_ref().map_or(0, |e| e.mem.len()); + let ee_len = self + .eeprom + .as_ref() + .map_or(0, |e| e.mem.len() + Eeprom::LINES_LEN); let mut out = Vec::with_capacity( 18 + self.vram.len() + ee_len + if self.chr_is_ram { self.chr.len() } else { 0 }, ); @@ -962,9 +1066,10 @@ impl Mapper for BandaiFcg { out.push(u8::from(self.irq_enabled)); out.push(u8::from(self.irq_pending)); out.push(self.eeprom_ctrl); - // EEPROM contents (if any). + // EEPROM contents and protocol state (if any). if let Some(ee) = self.eeprom.as_ref() { out.extend_from_slice(&ee.mem); + ee.save_lines(&mut out); } out.extend_from_slice(&self.vram); if self.chr_is_ram { @@ -980,7 +1085,10 @@ impl Mapper for BandaiFcg { } fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> { - let ee_len = self.eeprom.as_ref().map_or(0, |e| e.mem.len()); + let ee_len = self + .eeprom + .as_ref() + .map_or(0, |e| e.mem.len() + Eeprom::LINES_LEN); let need_chr = if self.chr_is_ram { self.chr.len() } else { 0 }; // Only the current version is read (v2.9.8, ADR 0042); a v1 blob, // which carried no mapper-153 tail, used to load on boards without one. @@ -1010,12 +1118,10 @@ impl Mapper for BandaiFcg { got: data.len(), }); } - if self.variant == FcgVariant::Datach { - self.load_datach_tail(datach_tail)?; - } - self.prg_bank = data[1]; - self.chr_banks.copy_from_slice(&data[2..10]); - self.mirroring = match data[10] { + // Validate everything before changing anything (CodeRabbit on #607: + // an unknown mirroring byte used to return Err after the Datach tail, + // the PRG bank and the CHR banks had already been restored). + let mirroring = match data[10] { 0 => Mirroring::Horizontal, 1 => Mirroring::Vertical, 2 => Mirroring::SingleScreenA, @@ -1023,6 +1129,16 @@ impl Mapper for BandaiFcg { 4 => Mirroring::FourScreen, other => return Err(MapperError::Invalid(format!("mirroring {other}"))), }; + if let Some(ee) = self.eeprom.as_ref() { + let at = 18 + ee.mem.len(); + Eeprom::check_lines(&data[at..at + Eeprom::LINES_LEN])?; + } + if self.variant == FcgVariant::Datach { + self.load_datach_tail(datach_tail)?; + } + self.prg_bank = data[1]; + self.chr_banks.copy_from_slice(&data[2..10]); + self.mirroring = mirroring; self.irq_latch = u16::from_le_bytes([data[11], data[12]]); self.irq_counter = u16::from_le_bytes([data[13], data[14]]); self.irq_enabled = data[15] != 0; @@ -1039,8 +1155,10 @@ impl Mapper for BandaiFcg { self.irq_pending = pending != 0; self.eeprom_ctrl = ctrl; if let Some(ee) = self.eeprom.as_mut() { - ee.mem.copy_from_slice(&data[cursor..cursor + ee.mem.len()]); - cursor += ee.mem.len(); + let n = ee.mem.len(); + ee.mem.copy_from_slice(&data[cursor..cursor + n]); + ee.load_lines(&data[cursor + n..cursor + n + Eeprom::LINES_LEN]); + cursor += n + Eeprom::LINES_LEN; } self.vram .copy_from_slice(&data[cursor..cursor + self.vram.len()]); @@ -1290,6 +1408,69 @@ mod tests { } } + /// A save state taken MID-TRANSACTION must restore the protocol, not just + /// the EEPROM's bytes (`CodeRabbit` on #604). Rollback, run-ahead and rewind + /// all restore at arbitrary cycles, and a game polling its save chip spans + /// many frames; before v3.2.0's version 3 the blob carried only `mem` and + /// `$800D`, so the restored board was Idle where the original was halfway + /// through a read, and the two diverged. + #[test] + fn a_state_saved_mid_read_restores_the_transaction() { + let variant = FcgVariant::Lz93d50_24c01; + let mut a = + BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap(); + a.eeprom.as_mut().unwrap().mem[0x05] = 0xAB; + { + let mut d = I2cDriver { m: &mut a }; + d.start(); + d.send_addr_rw(0x05, true); + assert!(d.read_ack(), "the X24C01 acknowledges its address"); + } + let blob = a.save_state(); + + let mut b = + BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap(); + b.eeprom.as_mut().unwrap().mem[0x05] = 0x00; // overwritten by the load + b.load_state(&blob).unwrap(); + + let from_a = I2cDriver { m: &mut a }.recv_data_lsb_first(); + let from_b = I2cDriver { m: &mut b }.recv_data_lsb_first(); + assert_eq!(from_a, 0xAB); + assert_eq!(from_b, from_a, "the restored board continues the same read"); + assert_eq!(b.save_state(), a.save_state()); + } + + /// The protocol bytes are validated before anything is restored: an + /// unknown mode tag, a counter past 8 or a line above 1 is refused, and the + /// board keeps its state. So is an unknown mirroring byte, which used to + /// return Err only after the banks had been restored (`CodeRabbit` on #607). + #[test] + fn a_corrupt_state_is_refused_before_anything_changes() { + let variant = FcgVariant::Lz93d50_24c02; + let mut m = + BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap(); + m.cpu_write(0x8008, 3); + let good = m.save_state(); + let lines = 18 + m.eeprom.as_ref().unwrap().mem.len(); + let mut other = + BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap(); + other.cpu_write(0x8008, 1); + let before = other.save_state(); + for (at, value, what) in [ + (lines + 2, 7, "mode tag"), + (lines + 7, 9, "bit counter"), + (lines, 2, "SCL level"), + (10, 9, "mirroring"), + ] { + let mut bad = good.clone(); + bad[at] = value; + assert!(other.load_state(&bad).is_err(), "{what} {value} refused"); + assert_eq!(other.save_state(), before, "{what}: nothing restored"); + } + other.load_state(&good).unwrap(); + assert_eq!(other.save_state(), good); + } + #[test] fn x24c01_write_then_read_round_trips() { let mut m = BandaiFcg::new( From 97e4ae11c3bca108bc640f9b1507ce2179d1fa80 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:00:32 -0400 Subject: [PATCH 079/104] fix(mappers): refuse corrupt states and impossible images before mutating Six findings from the mapper slices' review (#607, #608), each confirmed against the code, each with a test that fails without its fix. - datach_barcode.rs: `BarcodeReader::load` accepted any module count up to 65,535. `scan` builds only EAN-8 (67) or EAN-13 (95) modules between two quiet zones, and `tick` empties the list when a scan ends, so only 0, 67 + 2q and 95 + 2q can occur; anything else is refused before the reader changes. - ffe_super_magic_card.rs: `new` sliced `t[..TRAINER_LEN]` in both board arms, so a trainer under 512 bytes panicked where `# Errors` promises `Invalid` (`parse` always passes 512; `new` is public). And `load_state` accepted any FDS IRQ phase: a value near u16::MAX makes the per-cycle `+= 3` panic in a debug build and wrap in a release one, so the two builds diverged. The phase (bytes 36-37) must be below FDS_IRQ_THIRDS and is checked before anything is restored. - m116_somari.rs: the MMC3 part was restored before the MMC1 part was validated, so an MMC1 refusal left new MMC3 state beside old MMC1/VRC2 state. The MMC3's previous state is kept and put back on that path. The test's first draft wrote MMC3 registers in the power-on VRC2 mode, where they never reach the MMC3; the mutation (no restore) passed it. Fixed by selecting MMC3 mode first; the mutation is caught. - m172_m173_txc.rs: the blob stored only "vertical or not", so a four-screen header (kept until the first $8000 write; the parser does not refuse it) came back Horizontal after a load. Now a tag under the existing 0x80 marker: H 0 and V 1 keep every earlier 0x80/0x81 blob reading the same; single-screen 2-3, four-screen 4, mapper-controlled 5. An unknown tag is refused before any field changes. - m252_waixing_vrc4.rs: pattern writes to pages other than 6 and 7 were dropped. The suggested fix was to forward them to the VRC4; writing the test showed that would not have worked, because the VRC4's own CHR-RAM writes unbanked (`addr % len`) but reads banked (`chr_offset`), so a CHR-less image cannot read back what it wrote. That is a separate, older VRC4 defect, ticketed T-VRC4-CHRRAM-BANKING. Instead `new` refuses an image with no CHR-ROM: the wiki describes a CHR-ROM board whose pages 6 and 7 are CHR-RAM. The only staged 252 dump has 128 KiB of CHR-ROM. - ffe_super_magic_card.rs and m230_reset_multicart.rs: "no reference- emulator source was read" removed from the module docs (a negative self-attestation; CodeRabbit on #607, and the provenance guardrails). Not changed, with replies on the threads: four slice artifacts or behaviour the wiki confirms (m207 is wired in lib.rs; ppu_peek is a required Mapper method in mapper.rs; mapper 158's nametable bit is CHR A17 per the wiki; mapper 230's reset is never called at power-on). Mutation: each new check removed in turn fails its test. Mapper crate: 1,085 tests pass; clippy -D warnings with and without default features. No emulation output changes (straight runs are untouched). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/datach_barcode.rs | 29 ++++++++ .../src/ffe_super_magic_card.rs | 55 +++++++++++++- crates/rustynes-mappers/src/m116_somari.rs | 10 ++- .../rustynes-mappers/src/m116_somari_tests.rs | 22 ++++++ crates/rustynes-mappers/src/m172_m173_txc.rs | 71 ++++++++++++++++--- .../src/m230_reset_multicart.rs | 2 +- .../rustynes-mappers/src/m252_waixing_vrc4.rs | 26 ++++++- 7 files changed, 200 insertions(+), 15 deletions(-) diff --git a/crates/rustynes-mappers/src/datach_barcode.rs b/crates/rustynes-mappers/src/datach_barcode.rs index f6795aa5..661cf00d 100644 --- a/crates/rustynes-mappers/src/datach_barcode.rs +++ b/crates/rustynes-mappers/src/datach_barcode.rs @@ -367,6 +367,15 @@ impl BarcodeReader { let count = usize::from(u16::from_le_bytes([header[0], header[1]])); let pos = u32::from_le_bytes([header[2], header[3], header[4], header[5]]); let remaining = u32::from_le_bytes([header[6], header[7], header[8], header[9]]); + // `scan` builds an EAN-8 (67 modules) or EAN-13 (95) symbol between two + // quiet zones and `tick` empties the list when the scan ends, so those + // are the only lengths a reader can hold. Anything else is a corrupt + // or hostile blob, refused before anything changes (CodeRabbit on + // #607: it used to accept any count up to 65,535). + let quiet = 2 * QUIET_MODULES as usize; + if count != 0 && count != 67 + quiet && count != 95 + quiet { + return None; + } let bytes = count.div_ceil(8); let packed = data.get(10..10 + bytes)?; if count > 0 && (pos as usize >= count || remaining == 0 || remaining > MODULE_CYCLES) { @@ -386,6 +395,26 @@ mod tests { use super::*; use alloc::format; use alloc::string::String; + use alloc::vec::Vec; + + #[test] + fn a_saved_scan_of_any_other_length_is_refused() { + let mut r = BarcodeReader::default(); + r.scan(&Barcode::parse("4006381333931").unwrap()); + let mut blob = Vec::new(); + r.save(&mut blob); + let mut fresh = BarcodeReader::default(); + assert_eq!(fresh.load(&blob), Some(blob.len()), "a real scan loads"); + // 1,000 modules, with packed bytes to match, so only the count is wrong. + let mut bad = Vec::new(); + bad.extend_from_slice(&1000u16.to_le_bytes()); + bad.extend_from_slice(&0u32.to_le_bytes()); + bad.extend_from_slice(&MODULE_CYCLES.to_le_bytes()); + bad.resize(10 + 1000usize.div_ceil(8), 0); + let mut other = BarcodeReader::default(); + assert_eq!(other.load(&bad), None); + assert!(!other.is_scanning(), "nothing restored"); + } fn as_text(modules: &[bool]) -> String { modules.iter().map(|&m| if m { '1' } else { '0' }).collect() diff --git a/crates/rustynes-mappers/src/ffe_super_magic_card.rs b/crates/rustynes-mappers/src/ffe_super_magic_card.rs index e3a0a413..a0d4f053 100644 --- a/crates/rustynes-mappers/src/ffe_super_magic_card.rs +++ b/crates/rustynes-mappers/src/ffe_super_magic_card.rs @@ -4,7 +4,7 @@ //! //! Written from the vendored `NESdev` pages `INES_Mapper_006`, //! `INES_Mapper_008`, `INES_Mapper_017` and the page they all point to for the -//! hardware, `Super_Magic_Card`; no reference-emulator source was read. +//! hardware, `Super_Magic_Card`. //! //! These are not cartridge boards. They are copier RAM cartridges: the game //! is loaded from a floppy into the Super Magic Card's 512 KiB of PRG DRAM and @@ -174,8 +174,8 @@ impl SuperMagicCard { /// # Errors /// /// [`MapperError::Invalid`] when the PRG image is empty, larger than the - /// 512 KiB DRAM or not a multiple of 8 KiB, or the CHR image is larger than - /// the 256 KiB CHR-RAM. + /// 512 KiB DRAM or not a multiple of 8 KiB, the CHR image is larger than + /// the 256 KiB CHR-RAM, or a trainer is shorter than its 512 bytes. pub fn new( board: FfeBoard, prg_rom: &[u8], @@ -196,6 +196,17 @@ impl SuperMagicCard { chr_rom.len() ))); } + // `parse` always passes exactly 512 bytes, but `new` is public, and both + // board arms slice `t[..TRAINER_LEN]` (CodeRabbit on #607: a short + // trainer panicked where `# Errors` promises `Invalid`). + if let Some(t) = trainer + && t.len() < TRAINER_LEN + { + return Err(MapperError::Invalid(format!( + "FFE trainer is {} bytes, expected {TRAINER_LEN}", + t.len() + ))); + } let mut prg = vec![0u8; PRG_DRAM].into_boxed_slice(); prg[..prg_rom.len()].copy_from_slice(prg_rom); let mut chr = vec![0u8; CHR_RAM].into_boxed_slice(); @@ -709,6 +720,16 @@ impl Mapper for SuperMagicCard { if data[0] != SAVE_STATE_VERSION { return Err(MapperError::UnsupportedVersion(data[0])); } + // The FDS IRQ phase only ever holds 0..FDS_IRQ_THIRDS (the tick wraps + // it), and `+= 3` on a value near u16::MAX would panic in a debug build + // and wrap in a release one, so the two builds diverged on a crafted + // state (CodeRabbit on #607). Checked before anything is restored. + let thirds = u16::from_le_bytes([data[36], data[37]]); + if thirds >= FDS_IRQ_THIRDS { + return Err(MapperError::Invalid(format!( + "FFE FDS IRQ phase {thirds} out of range (below {FDS_IRQ_THIRDS})" + ))); + } let mut c = 1; let mut byte = || { let v = data[c]; @@ -763,6 +784,34 @@ mod tests { use super::*; use crate::parse; + #[test] + fn a_short_trainer_is_an_error_not_a_panic() { + let prg = vec![0u8; BANK_8K * 4]; + for board in [FfeBoard::MagicCard(1), FfeBoard::SuperMagicCard(0)] { + let r = SuperMagicCard::new(board, &prg, &[], Some(&[0u8; 100]), false); + assert!(matches!(r, Err(MapperError::Invalid(_))), "{board:?}"); + } + } + + #[test] + fn a_state_with_an_impossible_fds_irq_phase_is_refused() { + let prg = vec![0u8; BANK_8K * 4]; + let m = SuperMagicCard::new(FfeBoard::SuperMagicCard(0), &prg, &[], None, false).unwrap(); + let good = m.save_state(); + let mut bad = good.clone(); + bad[36..38].copy_from_slice(&0xFFFEu16.to_le_bytes()); + let mut other = + SuperMagicCard::new(FfeBoard::SuperMagicCard(0), &prg, &[], None, false).unwrap(); + other.cpu_write(0x4501, 0x01); + let before = other.save_state(); + assert!(matches!( + other.load_state(&bad), + Err(MapperError::Invalid(_)) + )); + assert_eq!(other.save_state(), before, "nothing restored"); + other.load_state(&good).unwrap(); + } + /// An image through `crate::parse`: `prg_16k` x 16 KiB PRG whose every /// 16 KiB page opens with its index, `chr_8k` x 8 KiB CHR likewise, NES /// 2.0 with `submapper` when given, and an optional 512-byte trainer. diff --git a/crates/rustynes-mappers/src/m116_somari.rs b/crates/rustynes-mappers/src/m116_somari.rs index f6539225..c990f1da 100644 --- a/crates/rustynes-mappers/src/m116_somari.rs +++ b/crates/rustynes-mappers/src/m116_somari.rs @@ -443,9 +443,17 @@ impl Mapper for Somari116 { }); } let mut cur = HEAD; + // The MMC3 part is restored first, so keep its old state: if the MMC1 + // part is then refused, put it back, and the board is left exactly as + // it was rather than half restored (CodeRabbit on #608). + let mmc3_prev = self.mmc3.save_state(); self.mmc3.load_state(&data[cur..cur + mmc3_len])?; cur += mmc3_len; - self.mmc1.load_state(&data[cur..cur + mmc1_len])?; + if let Err(e) = self.mmc1.load_state(&data[cur..cur + mmc1_len]) { + // Its own blob, just produced, so this restore cannot fail. + let _ = self.mmc3.load_state(&mmc3_prev); + return Err(e); + } cur += mmc1_len; self.supervisor = data[1] & 0x07; self.vrc2_prg = [data[2] & 0x1F, data[3] & 0x1F]; diff --git a/crates/rustynes-mappers/src/m116_somari_tests.rs b/crates/rustynes-mappers/src/m116_somari_tests.rs index 1b19d730..54c27266 100644 --- a/crates/rustynes-mappers/src/m116_somari_tests.rs +++ b/crates/rustynes-mappers/src/m116_somari_tests.rs @@ -303,3 +303,25 @@ fn m116_state_round_trip() { bad[13..17].copy_from_slice(&u32::MAX.to_le_bytes()); assert!(board().load_state(&bad).is_err()); } + +/// A state whose MMC1 part is refused must leave the board as it was: the MMC3 +/// part used to be restored first and kept when the MMC1 part then failed, +/// mixing new MMC3 state with the old MMC1, VRC2 and RAM (`CodeRabbit` on #608). +#[test] +fn m116_a_refused_mmc1_part_leaves_the_mmc3_untouched() { + let mut src = board(); + // MMC3 mode first: in the power-on VRC2 mode these writes never reach the + // MMC3, the two boards' MMC3 states would be equal, and the test could not + // see the bug (its first draft did exactly that; mutation found it). + src.cpu_write(0x4100, 0x01); + src.cpu_write(0x8000, 0x06); // MMC3 bank select: R6 + src.cpu_write(0x8001, 0x03); + let mut bad = src.save_state(); + let mmc3_len = u32::from_le_bytes([bad[13], bad[14], bad[15], bad[16]]) as usize; + bad[HEAD + mmc3_len] = 0xEE; // the MMC1 blob's version byte + + let mut m = board(); + let before = m.save_state(); + assert!(m.load_state(&bad).is_err()); + assert_eq!(m.save_state(), before, "nothing restored"); +} diff --git a/crates/rustynes-mappers/src/m172_m173_txc.rs b/crates/rustynes-mappers/src/m172_m173_txc.rs index aef97132..a0bdcc45 100644 --- a/crates/rustynes-mappers/src/m172_m173_txc.rs +++ b/crates/rustynes-mappers/src/m172_m173_txc.rs @@ -136,6 +136,35 @@ fn prg_byte(prg: &[u8], addr: u16) -> u8 { prg[off] } +/// The save-state tag for a mirroring, below the `0x80` marker bit. H and V +/// are 0 and 1, the values the first encoding wrote (`0x80` / `0x81`), so +/// every earlier blob still reads the same. That encoding saved only "vertical +/// or not": `new` keeps the header's mirroring until the first `$8000` write, +/// and a four-screen header is not refused by the parser, so such a dump came +/// back Horizontal after a load and its nametables moved (`CodeRabbit` on #608). +const fn mirroring_tag(m: Mirroring) -> u8 { + match m { + Mirroring::Horizontal => 0, + Mirroring::Vertical => 1, + Mirroring::SingleScreenA => 2, + Mirroring::SingleScreenB => 3, + Mirroring::FourScreen => 4, + Mirroring::MapperControlled => 5, + } +} + +const fn mirroring_from_tag(t: u8) -> Option { + Some(match t { + 0 => Mirroring::Horizontal, + 1 => Mirroring::Vertical, + 2 => Mirroring::SingleScreenA, + 3 => Mirroring::SingleScreenB, + 4 => Mirroring::FourScreen, + 5 => Mirroring::MapperControlled, + _ => return None, + }) +} + fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize { let table = (((addr - 0x2000) / 0x0400) & 0x03) as u8; mirroring.physical_bank(table) * NAMETABLE_SIZE + (usize::from(addr) & (NAMETABLE_SIZE - 1)) @@ -316,7 +345,7 @@ impl Mapper for Txc172 { out.push(self.output); out.push(u8::from(self.increment)); out.push(u8::from(self.invert)); - out.push(u8::from(self.mirroring == Mirroring::Vertical) | 0x80); + out.push(mirroring_tag(self.mirroring) | 0x80); out.extend_from_slice(&self.vram); out } @@ -332,19 +361,16 @@ impl Mapper for Txc172 { if data[0] != SAVE_STATE_VERSION { return Err(MapperError::UnsupportedVersion(data[0])); } + // Decoded before any field changes, so an unknown tag leaves the board + // as it was. + let mirroring = mirroring_from_tag(data[6] & 0x7F) + .ok_or_else(|| MapperError::Invalid(format!("TXC mirroring tag {:#04x}", data[6])))?; self.input = data[1] & 0x3F; self.register = data[2] & 0x3F; self.output = data[3] & 0x3F; self.increment = data[4] != 0; self.invert = data[5] != 0; - // Bit 7 marks a saved H/V choice; the header's mirroring is the only - // other value the board can hold, and it is H or V on every dump, so - // the bit-0 encoding is complete. - self.mirroring = if data[6] & 0x01 != 0 { - Mirroring::Vertical - } else { - Mirroring::Horizontal - }; + self.mirroring = mirroring; self.vram.copy_from_slice(&data[7..]); Ok(()) } @@ -702,6 +728,33 @@ mod tests { assert!(b.load_state(&blob[1..]).is_err()); } + /// A four-screen header's mirroring survives a save and load (it came + /// back Horizontal when the blob stored only "vertical or not"), an old + /// `0x81` blob still reads as Vertical, and an unknown tag is refused with + /// the board unchanged. + #[test] + fn m172_state_keeps_a_four_screen_header_and_refuses_unknown_tags() { + let mut img = image(172, 2, 4); + img[6] |= 0x08; // four-screen + let a = parse(&img).unwrap().1; + assert_eq!(a.current_mirroring(), Mirroring::FourScreen, "header"); + let blob = a.save_state(); + let mut b = board(172, 2, 4); + b.load_state(&blob).unwrap(); + assert_eq!(b.current_mirroring(), Mirroring::FourScreen); + + let mut old = blob.clone(); + old[6] = 0x81; + b.load_state(&old).unwrap(); + assert_eq!(b.current_mirroring(), Mirroring::Vertical); + + let mut bad = blob; + bad[6] = 0x86; // tags 0-5 are the six mirrorings + let before = b.save_state(); + assert!(b.load_state(&bad).is_err()); + assert_eq!(b.save_state(), before, "nothing restored"); + } + // --- 173 --------------------------------------------------------------- #[test] diff --git a/crates/rustynes-mappers/src/m230_reset_multicart.rs b/crates/rustynes-mappers/src/m230_reset_multicart.rs index f6fbe1ae..015bb485 100644 --- a/crates/rustynes-mappers/src/m230_reset_multicart.rs +++ b/crates/rustynes-mappers/src/m230_reset_multicart.rs @@ -2,7 +2,7 @@ //! Mapper 230: the reset-switched *22-in-1* multicart (v3.2.0). //! //! Written from the vendored `NESdev` page `INES_Mapper_230`, which carries -//! Disch's notes; no reference-emulator source was read. The cartridge holds +//! Disch's notes. The cartridge holds //! two PRG chips back to back in the image: chip 0, 128 KiB, is *Contra*; //! chip 1, 512 KiB, is the multicart. "Performing a Soft Reset switches //! between them" -- the console's RESET button is the mode switch, delivered diff --git a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs index 1432e46d..f2988669 100644 --- a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs +++ b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs @@ -50,12 +50,25 @@ impl Waixing252 { /// # Errors /// /// As [`Vrc4::new`]: PRG a non-zero multiple of 8 KiB, CHR-ROM a multiple - /// of 1 KiB. + /// of 1 KiB. Also when there is no CHR-ROM: the board is CHR-ROM with two + /// pages of CHR-RAM ("1 KiB CHR pages 6 and 7 are CHR-RAM", + /// `INES_Mapper_252`), and without ROM `Vrc4::new` would build 8 KiB of + /// CHR-RAM that this wrapper's page-6/7 routing never reaches. pub fn new( prg_rom: Box<[u8]>, chr_rom: Box<[u8]>, mirroring: Mirroring, ) -> Result { + // CodeRabbit on #608 found writes to pages 0-5 dropped on a CHR-less + // image. Such an image is not this board, so it is refused here rather + // than routed (the VRC4's own CHR-RAM writes unbanked but reads + // banked, T-VRC4-CHRRAM-BANKING, so routing would not have worked). + if chr_rom.is_empty() { + return Err(MapperError::Invalid( + "mapper 252 is a CHR-ROM board (pages 6 and 7 are CHR-RAM); this image has no CHR-ROM" + .into(), + )); + } Ok(Self { inner: Vrc4::new(prg_rom, chr_rom, 23, 2, mirroring)?, chr_ram: vec![0u8; 2 * CHR_BANK_1K].into_boxed_slice(), @@ -137,6 +150,8 @@ impl Mapper for Waixing252 { self.chr_ram[off] = value; } // Pages other than 6 and 7 are CHR-ROM: a write there goes nowhere. + // `new` refuses an image without CHR-ROM, so there is no CHR-RAM + // in the VRC4 for such a write to have reached. return; } self.inner.ppu_write(addr, value); @@ -274,6 +289,15 @@ mod tests { assert_eq!(m.ppu_read(0x0000), 0x16); } + /// An image with no CHR-ROM is not this board: refused, rather than built + /// with pattern writes that go nowhere. + #[test] + fn m252_without_chr_rom_is_refused() { + let prg = vec![0u8; 0x8000].into_boxed_slice(); + let r = Waixing252::new(prg, Box::new([]), Mirroring::Vertical); + assert!(matches!(r, Err(MapperError::Invalid(_)))); + } + #[test] fn m252_prg_ram_and_irq_come_from_the_vrc4() { let mut m = board(); From 409aff26dc37c0fd30c8803adc0526a1f80657c3 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:00:53 -0400 Subject: [PATCH 080/104] fix(frontend): four review findings (netplay, Datach, save-file mode) From #604's and #605's review, each re-checked against the code. - netplay_ui.rs: the tick that ends a session on a peer timeout returned `active: false`, which by `NetplayTick`'s own contract tells the caller to run a single-player `run_frame()`: one stray frame on the failure tick. Every other `fail()` path returns `active: true`. New test `the_peer_timeout_tick_is_still_active` (two loopback peers reach InGame, the joiner goes silent, the host's last contact is backdated six seconds, the failing tick must be active); reverting the line fails it. - app.rs: a successful Datach scan now clears the history timeline, as a Reset does (NF-13): a .rnm records input only, so a clip spanning the scan could not replay it. The FDS disk-swap paths have the same, older gap; ticketed T-HISTORY-DISK-SWAP rather than changed here. - ui_shell.rs: loading a game without a reader returned before clearing `show_barcode`, so the window reopened by itself on the next Datach game. No unit test: the window is drawn inside the egui pass, which has no seam in this module. - atomic_write.rs: property 5 ("mode applied at creation, then set exactly") held only for files that already existed; a NEW file was left at `0o600 & ~umask`, narrower than 0o600 under a umask masking owner bits. The exact set now runs for new files too. No new test: forcing such a umask is process-wide and would race every other test creating files; the existing umask test covers the exact-set path. clippy -D warnings (default and `full`), frontend lib tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-frontend/src/app.rs | 12 ++++- crates/rustynes-frontend/src/atomic_write.rs | 9 ++-- crates/rustynes-frontend/src/netplay_ui.rs | 51 +++++++++++++++++++- crates/rustynes-frontend/src/ui_shell.rs | 9 +++- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs index 04d09cec..4d3f6cf0 100644 --- a/crates/rustynes-frontend/src/app.rs +++ b/crates/rustynes-frontend/src/app.rs @@ -2706,10 +2706,18 @@ impl App { fn scan_barcode(&mut self, code: &str) { let result = { let mut guard = self.emu.lock(); - let Some(nes) = guard.nes.as_mut() else { + let emu = &mut *guard; + let Some(nes) = emu.nes.as_mut() else { return; }; - nes.scan_barcode(code) + let r = nes.scan_barcode(code); + // A scan is input a `.rnm` does not record, so, as for a Reset + // (NF-13), a history clip spanning it could not replay it: start a + // fresh timeline (`CodeRabbit` on #604). + if r.is_ok() { + emu.history.clear(); + } + r }; let message = match result { Ok(()) => StatusMessage::info(crate::tf!(ShellBarcodeScanned, code)), diff --git a/crates/rustynes-frontend/src/atomic_write.rs b/crates/rustynes-frontend/src/atomic_write.rs index 649e52b8..e3a37d7a 100644 --- a/crates/rustynes-frontend/src/atomic_write.rs +++ b/crates/rustynes-frontend/src/atomic_write.rs @@ -652,10 +652,13 @@ where // fails is replaced by one at the umask default -- which is WIDER. Reporting // success there hands the caller a file with weaker permissions than the one // it replaced, and says nothing. Found in review of the PR that introduced it. + // + // A NEW file gets the same exact set, at 0o600: creation alone gives + // `0o600 & !umask`, so under a umask that masks owner bits the file landed + // narrower than property 5 promises ("then set exactly"). Only existing + // files were set until `CodeRabbit` on #605. #[cfg(unix)] - if let Some(mode) = existing_mode - && let Err(e) = apply_mode_using(&tmp, mode, set_permissions_mode) - { + if let Err(e) = apply_mode_using(&tmp, existing_mode.unwrap_or(0o600), set_permissions_mode) { let _ = fs::remove_file(&tmp); return Err(e); } diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs index f52558f0..049db989 100644 --- a/crates/rustynes-frontend/src/netplay_ui.rs +++ b/crates/rustynes-frontend/src/netplay_ui.rs @@ -497,8 +497,12 @@ impl NetplayUi { && self.last_peer_activity.elapsed() > std::time::Duration::from_secs(5) { self.fail("peer connection lost (no data for several seconds)".to_string()); + // `active: true`, like every other `fail()` path: the tick + // was the session's, and `false` would tell the caller to + // run a single-player frame on the failure tick (CodeRabbit + // on #604 and #605). return NetplayTick { - active: false, + active: true, produced_frame: false, }; } @@ -783,6 +787,51 @@ mod tests { assert_ne!(join.phase(), NetplayPhase::Error, "joiner did not error"); } + /// The tick that ends a session on a peer timeout still belongs to the + /// session: `active: true`, as on every other failure path, so the caller + /// does not run a stray single-player frame on it (`CodeRabbit` on #604). + #[test] + fn the_peer_timeout_tick_is_still_active() { + let rom = synth_nrom(); + let hash = SessionIdentity::of(&Nes::from_rom(&rom).unwrap()); + let probe = UdpSocket::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap(); + let host_addr = probe.local_addr().unwrap(); + drop(probe); + let mut host = NetplayUi::default(); + host.start_host(host_addr.port(), 2, hash); + let mut join = NetplayUi::default(); + join.start_join(host_addr, hash); + let mut nes_host = Nes::from_rom(&rom).unwrap(); + let mut nes_join = Nes::from_rom(&rom).unwrap(); + let mut rounds = 0; + while !(host.phase() == NetplayPhase::InGame && join.phase() == NetplayPhase::InGame) + && rounds < 500 + { + host.tick(&mut nes_host, Buttons::empty()); + join.tick(&mut nes_join, Buttons::empty()); + rounds += 1; + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(host.phase(), NetplayPhase::InGame); + + // The joiner goes silent; the host's last contact is six seconds ago. + let stale = std::time::Instant::now() + .checked_sub(std::time::Duration::from_secs(6)) + .unwrap(); + let mut failing_tick = None; + for _ in 0..300 { + host.last_peer_activity = stale; + let t = host.tick(&mut nes_host, Buttons::empty()); + if host.phase() == NetplayPhase::Error { + failing_tick = Some(t); + break; + } + } + let t = failing_tick.expect("the host gives up on a silent peer"); + assert!(t.active, "the failing tick is the session's"); + assert!(!t.produced_frame); + } + /// v2.9.0 (`CodeRabbit` on #561): v2.9.0 made a power cycle keep battery /// RAM, and the session start was a power cycle, so two peers holding /// different saves began the "identical" cold boot with different save diff --git a/crates/rustynes-frontend/src/ui_shell.rs b/crates/rustynes-frontend/src/ui_shell.rs index da0ff87e..7ceb2a21 100644 --- a/crates/rustynes-frontend/src/ui_shell.rs +++ b/crates/rustynes-frontend/src/ui_shell.rs @@ -2502,7 +2502,14 @@ impl UiShell { frame: &ShellFrame<'_>, out: &mut ShellOutput, ) { - if !self.show_barcode || !frame.barcode_reader { + // A cartridge without a reader also closes the window: returning early + // left `show_barcode` set, so it reopened by itself on the next Datach + // game (`CodeRabbit` on #604). + if !frame.barcode_reader { + self.show_barcode = false; + return; + } + if !self.show_barcode { return; } let locked = frame.movie_recording || frame.movie_playing || frame.netplay_active; From b40859f2be49c4d815fff37070f1e5d800ba3f0f Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:01:01 -0400 Subject: [PATCH 081/104] fix(mobile): FDS saves on Android and iOS, and the saved-disk boot guard From #604's and #605's review. Android (MainActivity.kt), two Cursor findings: - A disk write could be lost. `diskIsDirty`, `diskImageBytes` and `clearDiskDirty` are three separately locked bridge calls, the game loop keeps running between them (until onStop, and in picture-in-picture), and the flag was cleared AFTER the write, discarding a disk write that landed between the copy and the clear. Now cleared BEFORE the copy, so a write during the save sets it again; a failed file write is kept in a `retry` set and flushed again; `flushIfDirty` is @Synchronized. - The disk was saved only from lifecycle events, so a process killed in the background lost everything since the last one. The game loop now checks it once a second, like the battery, on Dispatchers.IO. Both flavours compile (compileFossDebugKotlin, compilePlayDebugKotlin) and the 20 foss unit tests pass. iOS (AppModel.swift): - Reopening the running FDS game booted the saved file BEFORE the outgoing disk's newest writes were flushed to it, and the late flush was then overwritten by the new core's first disk write (CodeRabbit). The flush moves ahead of `bootSavedDiskIfAny`; `currentEntry` and `emulator` are still the outgoing game's there, so it keys and reads the outgoing disk as before. - The same clear-first change as Android, with a retry set; the background flush also runs for a disk waiting in it, and ends its task with `defer` like the battery path (CodeRabbit). Not compiled here (no macOS): the iOS workflow on this branch is the check. A periodic iOS disk flush is ticketed (T-IOS-FDS-PERIODIC), as is a durable-write helper for every iOS store (T-IOS-DURABLE-WRITE). Bridge (rustynes-mobile): `boot_saved_disk` replaced the console without the NF-18 timeline guard every sibling mutator applies; it now refuses under a movie or netplay session and resets `frozen`/`coin_frames` like a state load. Both apps call it before the controller can run, so this guards the public API. New test `a_saved_disk_is_refused_while_a_movie_ records`; removing the check fails it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../com/doublegate/rustynes/MainActivity.kt | 41 ++++++++++++++++--- crates/rustynes-mobile/src/lib.rs | 38 +++++++++++++++++ ios/RustyNES/AppModel.swift | 41 ++++++++++++++----- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt index 1b688340..e7a2639a 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt @@ -781,13 +781,26 @@ internal object FdsSaves { }.onFailure { android.util.Log.w("RustyNES", "saved FDS disk not used", it) } } + /** Disks whose last write failed: flushed again even though now clean. */ + private val retry = mutableSetOf() + /** * Write the disk if the game changed it, atomically ([android.util.AtomicFile]: - * a crash mid-write keeps the previous file). The dirty flag is cleared only - * after the write lands, so a failure is retried by the next flush. + * a crash mid-write keeps the previous file). + * + * The dirty flag is cleared BEFORE the image is copied. `diskIsDirty`, + * `diskImageBytes` and `clearDiskDirty` are three separately locked bridge + * calls, and the game loop keeps running between them (into `onStop`, and + * in picture-in-picture), so clearing after the write discarded any disk + * write that landed between the copy and the clear (Cursor on #605). A write + * after the clear sets the flag again and is caught by the next flush; a + * failed file write is remembered in [retry] and flushed again. Synchronized + * because the game loop's periodic flush and the lifecycle flush both call it. */ + @Synchronized fun flushIfDirty(context: Context, ctrl: NesController, sha: String) { - if (!ctrl.diskIsDirty()) return + if (!ctrl.diskIsDirty() && sha !in retry) return + ctrl.clearDiskDirty() val bytes = ctrl.diskImageBytes() runCatching { val f = file(context, sha) @@ -801,8 +814,11 @@ internal object FdsSaves { atomic.failWrite(out) throw e } - ctrl.clearDiskDirty() - }.onFailure { android.util.Log.w("RustyNES", "FDS disk save failed", it) } + retry.remove(sha) + }.onFailure { + retry.add(sha) + android.util.Log.w("RustyNES", "FDS disk save failed", it) + } } } @@ -2824,6 +2840,8 @@ private fun EmulatorScreen( // v2.7.4 (AND-09): the battery save is compared once a second (60 frames) // and written on a change, on Dispatchers.IO so the loop never waits on disk. var batteryFrame = 0 + // v3.2.0: an FDS game's disk is checked on the same once-a-second cadence. + var fdsFrame = 0 // v2.7.4: host warnings are drained on the same low cadence (see below). var warnFrame = 0 // v1.8.8 "Atlas" (Workstream E): accumulate frames run with fast-forward @@ -2874,6 +2892,19 @@ private fun EmulatorScreen( } } } + // v3.2.0: an FDS disk was written only from the lifecycle flush + // (onPause/onStop, a ROM switch), so a process killed in the + // background lost every disk write since the last one; the + // desktop flushes it every frame (Cursor on #605). Once a + // second, like the battery above; flushIfDirty is synchronized + // and clears the flag first, so a write during it is kept. + val fdsSha = emulator.romSha + if (fdsSha != null && ++fdsFrame >= 60) { + fdsFrame = 0 + if (ctrl.diskIsDirty()) { + launch(Dispatchers.IO) { FdsSaves.flushIfDirty(context, ctrl, fdsSha) } + } + } // Advance the turbo/autofire pulse once per emulated frame, then push // the per-port masks updated below. Cheap no-op when nothing is held // in turbo. Must run BEFORE the input is latched into the core. diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs index 35e382ce..ce43373b 100644 --- a/crates/rustynes-mobile/src/lib.rs +++ b/crates/rustynes-mobile/src/lib.rs @@ -1786,6 +1786,16 @@ impl NesController { })?; nes.set_rom_identity(pristine); let mut g = self.lock(); + // Replacing the console mid-movie or mid-session would desynchronise + // it, as a Reset or a state load would, so the same NF-18 rule applies + // (`CodeRabbit` on #604 and #605). Both hosts call this right after + // building the controller, before either can exist; this guards the + // public API, not a path the apps take today. + if let Some(why) = timeline_refusal(&g) { + return Err(MobileError::RomLoad { + reason: format!("saved disk boot refused: {why}"), + }); + } // Install it only over the disk it was read for: a different game // loaded while the lock was released must not be replaced. if *g.nes.rom_sha256() != pristine { @@ -1794,6 +1804,10 @@ impl NesController { }); } g.nes = nes; + // A whole new console: nothing a contained panic or a coin pulse left + // behind applies to it (as after a state load). + g.frozen = false; + g.coin_frames = 0; drop(g); Ok(()) } @@ -4330,6 +4344,30 @@ mod tests { assert_eq!(cart.rom_identity(), before, "and keeps running"); } + /// While a movie owns the timeline the saved disk is refused, as a Reset + /// or a state load is (NF-18): replacing the console would desynchronise + /// the recording (`CodeRabbit` on #604 and #605). + #[test] + fn a_saved_disk_is_refused_while_a_movie_records() { + let ctrl = NesController::new_with_fds_bios( + synthetic_fds_disk(1), + Some(synthetic_fds_bios()), + DEFAULT_SAMPLE_RATE, + ) + .expect("disk loads"); + let mut saved = ctrl.disk_image_bytes(); + let last = saved.len() - 1; + saved[last] ^= 0x5A; + ctrl.movie_record_from_here(); + assert!(ctrl.movie_is_recording()); + let before = ctrl.disk_image_bytes(); + assert!( + ctrl.boot_saved_disk(saved).is_err(), + "refused under a movie" + ); + assert_eq!(ctrl.disk_image_bytes(), before, "the running disk is kept"); + } + /// Plan item 6: the BIOS set on a controller persists across `load_rom`, /// so a host that stored it once can swap disks without re-supplying it. #[test] diff --git a/ios/RustyNES/AppModel.swift b/ios/RustyNES/AppModel.swift index d2f3f132..41e3187e 100644 --- a/ios/RustyNES/AppModel.swift +++ b/ios/RustyNES/AppModel.swift @@ -345,6 +345,14 @@ final class AppModel: ObservableObject { // everything, the save file included. On any failure the pristine disk // stays loaded. Before the battery and the auto-resume state, which are // newer. + // + // The OUTGOING game's disk is flushed first: reopening the running FDS + // game used to boot the file before its newest writes reached it, and + // the flush that followed was then overwritten by the new core's first + // disk write (CodeRabbit on #605). `currentEntry` and `emulator` are + // still the outgoing game's here (both change below), so the flush + // keys and reads the outgoing disk, as it did when it ran later. + flushBatteryNow() AppModel.bootSavedDiskIfAny(core, sha: entry.sha) core.isMuted = muted // v2.9.2 (AUD-14): pace the gamepad turbo pulse by emulated frames. @@ -352,9 +360,9 @@ final class AppModel: ObservableObject { // touches no main-actor state of this model. let pads = gamepads core.onFrameWillRun = { [weak pads] in pads?.advanceTurboFrame() } - // v2.7.4 (MOB-05): the outgoing game's final battery write, then the - // incoming game's `.sav`, loaded before its first frame. - flushBatteryNow() + // v2.7.4 (MOB-05): the outgoing game's final battery write (done above, + // with its disk), then the incoming game's `.sav`, loaded before its + // first frame. let batteryNotice = attachBattery(core: core, sha: entry.sha) // Tear any prior session's connectivity state down before freeing its core // (RA persists per-game progress; netplay ends on a ROM swap). @@ -951,18 +959,29 @@ final class AppModel: ObservableObject { } } - /// Write the running disk if the game changed it, atomically. The dirty flag - /// is cleared only after the write lands, so a failure is retried by the - /// next flush. + /// Disks whose last write failed: flushed again even though now clean. + private var fdsRetry: Set = [] + + /// Write the running disk if the game changed it, atomically. + /// + /// The dirty flag is cleared BEFORE the image is copied: the three bridge + /// calls are separately locked and the emulation thread keeps running, so + /// clearing after the write discarded a disk write that landed in between + /// (Cursor on #605, for Android; the same shape here). A write after the + /// clear sets the flag again; a failed file write is retried via `fdsRetry`. private func flushFdsDiskIfDirty() { - guard let core = emulator, let sha = currentEntry?.sha, core.diskIsDirty else { return } + guard let core = emulator, let sha = currentEntry?.sha else { return } + guard core.diskIsDirty || fdsRetry.contains(sha) else { return } + core.clearDiskDirty() + let bytes = core.diskImageBytes() let url = AppModel.fdsSaveUrl(forSha: sha) do { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try core.diskImageBytes().write(to: url, options: .atomic) - core.clearDiskDirty() + try bytes.write(to: url, options: .atomic) + fdsRetry.remove(sha) } catch { + fdsRetry.insert(sha) NSLog("RustyNES: FDS disk save failed: \(error)") } } @@ -1013,10 +1032,10 @@ final class AppModel: ObservableObject { /// is a few KiB, so it runs synchronously within the task rather than handing /// a mutable task id across threads. private func flushBatteryInBackground() { - if emulator?.diskIsDirty == true { + if emulator?.diskIsDirty == true || !fdsRetry.isEmpty { let task = BackgroundTask(name: "RustyNES FDS save") + defer { task.end() } flushFdsDiskIfDirty() - task.end() } guard let saver = battery, let core = emulator else { return } let task = BackgroundTask(name: "RustyNES battery save") From 8b8ecb4e0a18f834ef3b8f55a6ecf6936eb011f7 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:01:08 -0400 Subject: [PATCH 082/104] fix(cosim): refuse --fb-every-frame without a frame run, up front CodeRabbit on #604 and #605. `--fb-every-frame` with `--inject-instructions` was silently ignored: the injection branch runs instead of the frame loop, no `index_fb_frames.bin` is written, and the exit is 0, the "armed but empty" golden this exporter already refuses for the APU trace. `--press-start` with it was refused, but only after the output directory, the devices and the traces had been set up. Both combinations are now refused in `injection_error` (the pure, testable check), and `validate_injection` runs first thing in `main`, before any side effect. The per-frame capture's reservation is capped at 600 frames (about 70 MiB); a larger run still grows the Vec, but a typo in `--frames` can no longer abort on the reservation alone. No golden changes: the export path of every recipe is unchanged. Also a test for the C ABI's unaligned `rn_index_framebuffer` branch, which no caller reached (CodeRabbit on #605): an odd address must receive the same bytes as the aligned copy. Its first draft zero-filled the buffer, and a mutant copying half the frame passed (this ROM's frame is mostly index 0); with a sentinel fill the mutant is caught. 45 cosim tests pass; clippy -D warnings (all features), fmt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../src/bin/nes_golden_export.rs | 46 ++++++++++++++++--- crates/rustynes-cosim/src/lib.rs | 16 +++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs index d8feba0c..e3d4a4b7 100644 --- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs +++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs @@ -721,6 +721,21 @@ fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, us /// Returns the reason rather than exiting, so the rules are testable without a /// process boundary. fn injection_error(args: &Args) -> Option { + // `--fb-every-frame` captures each frame of a plain frame run. With + // `--inject-instructions` the injection branch runs instead and no frame + // completes, so the flag was silently ignored: no `index_fb_frames.bin`, + // exit 0, the "armed but empty" shape refused below for the APU trace + // (`CodeRabbit` on #604 and #605). `--press-start` was refused too, but + // only after the output directory, the devices and the traces were set + // up; both are refused here, before anything happens. + if args.fb_every_frame && (args.inject_instructions > 0 || args.press_start.is_some()) { + return Some( + "--fb-every-frame needs a plain frame run (no --press-start, no \ + --inject-instructions): an injection run completes no frames, and no \ + recipe combines it with --press-start" + .to_owned(), + ); + } // The APU trace is drained at FRAME boundaries, in `advance_frames`, and an // injection run steps instructions and completes no frames -- so the two // together would write a well-formed, EMPTY `.apu.bin` with a dropped count @@ -1133,12 +1148,12 @@ fn run_frames_with_optional_press( /// Refused with `--press-start` rather than combined with it: no recipe needs /// both, and an unexercised combination is a place a golden goes quietly wrong. fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 { - if args.press_start.is_some() { - eprintln!("--fb-every-frame is not supported together with --press-start"); - usage(); - } + // `injection_error` has refused `--press-start` with this flag already. let mut calls = 0u64; - let mut bytes = Vec::with_capacity(INDEX_FB_LEN * 2 * args.frames as usize); + // Reserve for at most 600 frames (about 70 MiB) up front; `frames` comes + // from the command line, and a reservation sized by it alone could abort + // on a typo before a single frame ran. The Vec grows past this if asked. + let mut bytes = Vec::with_capacity(INDEX_FB_LEN * 2 * (args.frames as usize).min(600)); for _ in 0..args.frames { calls += o.advance_frames(1); let fb = o.nes().index_framebuffer(); @@ -1157,6 +1172,9 @@ fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 { fn main() { let args = parse_args(); + // Refuse a contradictory argument set before ANY side effect: no output + // directory, no devices, no traces. + validate_injection(&args); let rom = std::fs::read(&args.rom).unwrap_or_else(|e| panic!("read {}: {e}", args.rom.display())); let stem = args @@ -1193,7 +1211,7 @@ fn main() { // number of instructions with a pin asserted for part of the run, which is // the stimulus rung 2 compares. A frame advance would run past the window // and bury the divergence under thousands of unrelated cycles. - validate_injection(&args); + // (`validate_injection` already ran, first thing in `main`.) let calls = if args.inject_instructions > 0 { o.run_with_injection( @@ -1394,6 +1412,22 @@ mod tests { sweep_only.inject_instructions = 8; sweep_only.inject_nmi_at = Some(2); assert!(injection_error(&sweep_only).is_none()); + + // `--fb-every-frame` with an injection run or `--press-start` is refused; + // with a plain frame run it is the normal sprite-limit recipe. + let mut fb_inject = base(); + fb_inject.fb_every_frame = true; + fb_inject.inject_instructions = 8; + fb_inject.inject_nmi_at = Some(2); + let err = injection_error(&fb_inject).expect("refused"); + assert!(err.contains("--fb-every-frame"), "{err}"); + let mut fb_start = base(); + fb_start.fb_every_frame = true; + fb_start.press_start = Some((10, 5)); + assert!(injection_error(&fb_start).is_some()); + let mut fb_frames = base(); + fb_frames.fb_every_frame = true; + assert!(injection_error(&fb_frames).is_none()); } use super::{Args, injection_error, sha256_hex, suffixed}; diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs index c7d40805..b5b6d976 100644 --- a/crates/rustynes-cosim/src/lib.rs +++ b/crates/rustynes-cosim/src/lib.rs @@ -1328,6 +1328,22 @@ mod tests { // A short buffer must be refused, not partially filled. assert_eq!(rn_index_framebuffer(h, fb.as_mut_ptr(), 10), 0); + // An odd address: the byte-copy branch, which no other call + // reaches (`CodeRabbit` on #605). Same bytes as the aligned copy. + let n = 256 * 240; + // A sentinel fill, not zeros: this ROM's frame is mostly index 0, + // so a copy that stopped short would still match a zeroed buffer + // (the first draft's mutant, half a copy, was not caught). + let mut raw = vec![0xA5u8; n * 2 + 1]; + let off = usize::from((raw.as_ptr() as usize) & 1 == 0); + // The misaligned pointer IS the subject: a C caller may hand one + // over, and the function must not do a `u16` copy through it. + #[allow(clippy::cast_ptr_alignment)] + let odd = raw.as_mut_ptr().add(off).cast::(); + assert_eq!(rn_index_framebuffer(h, odd, n), n); + let want: Vec = fb.iter().flat_map(|p| p.to_ne_bytes()).collect(); + assert_eq!(&raw[off..off + n * 2], &want[..]); + let mut ram = vec![0u8; 2048]; assert_eq!(rn_ram(h, ram.as_mut_ptr(), ram.len()), 2048); assert_eq!(rn_ram(h, ram.as_mut_ptr(), 10), 0); From 4f9cac8e449945dc4a96ab9be9caffd02899385c Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:01:24 -0400 Subject: [PATCH 083/104] fix(scripts): the promoted tools did less than their docs claimed The review of #604 and #605 (CodeRabbit, Codex) found nine defects in the three tools promoted from salvaged/ at 5a7d8b2b. Each reproduced or read against the script before it was changed. quiet_ab.sh - Admission was check-then-touch on two marker files, so two runs, or a run and a heavy job, could both pass; and two `heavy` jobs shared one BUSY file, the first to finish removing it under the second. Now one flock: a measurement holds it exclusive, `heavy` holds it shared. BUSY/MEASURING are informational. Tested: two concurrent heavy jobs ran together, BUSY stayed until the last finished, and each exit status (7, 1) propagated. - `run_one` returned the status of its trailing grep, not ab_check.sh's; the queue now records `rc=` per line, and an `.inprogress` marker names an interrupted run, refusing to start until it is dealt with. branch_contained.py - Containment was a substring test: `return value;` passed against `return value + 1;`. A line must now EQUAL a stripped line of the file. - An untracked file passed when only its path existed on main; now its contents must match. - Binary changes carry no lines and were invisible; they are compared by blob (committed) or file (uncommitted) now. Synthetic-repository tests: a squash merge passes; the substring trap, an unmerged binary and a differing untracked file each fail, named. run_gates.sh - `--only` with no argument looped forever (`shift 2` with one argument left fails without shifting, and there is no set -e): `timeout 5` exited 124. Now exit 2, "--only needs a regex". - The header claimed "every Rust gate CI runs"; it says lint, build and test, and names the four CI jobs it does not transcribe (bench, fuzz, coverage, libretro-cross). The README row says the same. - `--help` printed through the first code line after a header edit; it now stops at the header's last line (same for quiet_ab.sh). shellcheck and ruff clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/perf/quiet_ab.sh | 43 ++++++++++-- .../release-automation/README-doc-tools.md | 13 ++-- .../release-automation/branch_contained.py | 68 ++++++++++++++++--- scripts/release-automation/run_gates.sh | 16 ++++- 4 files changed, 116 insertions(+), 24 deletions(-) diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh index 14d9d194..33a2cd83 100755 --- a/scripts/perf/quiet_ab.sh +++ b/scripts/perf/quiet_ab.sh @@ -43,17 +43,27 @@ log_dir="${LOG_DIR:-$state/logs}" quiet="${QUIET_LOAD:-1.5}" busy="$state/BUSY" measuring="$state/MEASURING" +lock="$state/lock" mkdir -p "$state" "$log_dir" load1() { cut -d' ' -f1 /proc/loadavg; } +# One flock decides who may run: a measurement holds it EXCLUSIVE, every heavy +# job holds it SHARED, so heavy jobs run together but never under a +# measurement, and a measurement waits for all of them. BUSY and MEASURING are +# informational markers only. They used to BE the lock, checked and then +# touched (a window between the two), and two heavy jobs shared one BUSY file, +# so the first to finish removed it while the second still ran (CodeRabbit on +# #605). run_one() { local label="$1" base="$2"; shift 2 local la + exec 9> "$lock" while :; do - if [ -e "$busy" ]; then sleep 30; continue; fi + flock -x 9 la=$(load1) if awk -v l="$la" -v q="$quiet" 'BEGIN { exit !(l < q) }'; then break; fi + flock -u 9 sleep 60 done echo "$label" > "$measuring" @@ -66,8 +76,12 @@ run_one() { echo "END $(date -Is) rc=$rc ($(cat /proc/loadavg))" } > "$log_dir/$label.log" 2>&1 rm -f "$measuring" - echo "$label done (start load $la), log $log_dir/$label.log" + exec 9>&- + echo "$label done (start load $la, rc=$rc), log $log_dir/$label.log" grep -E "^LABEL|^START|^END|change:|^==>" "$log_dir/$label.log" + # The function's status is ab_check.sh's, not the grep's (which always + # matches the LABEL line and so reported every run as a success). + return "$rc" } case "${1:-}" in @@ -75,10 +89,16 @@ case "${1:-}" in shift [ $# -ge 2 ] || { echo "usage: quiet_ab.sh run LABEL BASE [ab_check args...]" >&2; exit 2; } trap 'rm -f "$measuring"' EXIT - run_one "$@" ;; + run_one "$@" + exit $? ;; queue) q="${2:-}" [ -f "$q" ] || { echo "usage: quiet_ab.sh queue FILE (FILE must exist)" >&2; exit 2; } + if [ -e "$q.inprogress" ]; then + echo "quiet_ab.sh: $q.inprogress names a run that was interrupted: $(cat "$q.inprogress")" >&2 + echo "quiet_ab.sh: re-queue it if it is still wanted, then delete the marker" >&2 + exit 2 + fi trap 'rm -f "$measuring"' EXIT while :; do line=$(grep -v '^[[:space:]]*$' "$q" | grep -v '^#' | head -1) @@ -91,18 +111,27 @@ lines = open(path).read().splitlines() lines.remove(line) open(path, "w").write("\n".join(lines) + ("\n" if lines else "")) PY + # An interrupted run is a void measurement: the marker says which one, + # so it is re-queued deliberately rather than silently lost. + echo "$line" > "$q.inprogress" # `line` holds LABEL BASE and flags, which must word-split. # shellcheck disable=SC2086 run_one $line - echo "$line" >> "$q.done" + echo "$line rc=$?" >> "$q.done" + rm -f "$q.inprogress" done ;; heavy) shift [ $# -ge 1 ] || { echo "usage: quiet_ab.sh heavy CMD [args...]" >&2; exit 2; } - while [ -e "$measuring" ]; do sleep 20; done + exec 9> "$lock" + flock -s 9 touch "$busy" - trap 'rm -f "$busy"' EXIT - "$@" ;; + "$@" + rc=$? + # Clear the marker only when no other heavy job still holds the lock. + flock -u 9 + if flock -n -x 9; then rm -f "$busy"; flock -u 9; fi + exit "$rc" ;; *) sed -n '/^set -uo/q;2,$p' "$0" exit 2 ;; diff --git a/scripts/release-automation/README-doc-tools.md b/scripts/release-automation/README-doc-tools.md index 7344689e..299b466b 100644 --- a/scripts/release-automation/README-doc-tools.md +++ b/scripts/release-automation/README-doc-tools.md @@ -79,8 +79,8 @@ next cut does not have to rebuild them. | script | needs | what it does | | --- | --- | --- | -| `run_gates.sh` | the repo's toolchain, `cargo-deny`, the wasm32 and thumbv7em targets | Runs every Rust gate `.github/workflows/ci.yml` runs, one cargo job at a time, and prints one PASS / FAIL / SKIP line per gate with a log per gate. `--only ` selects gates, `--no-roms` skips the ROM suites. The commercial suite is SKIP, never PASS, when no dumps are staged. | -| `branch_contained.py` | stdlib only | Before deleting a squash-merged branch or a worktree: checks that every line the branch added (committed from the merge base, plus a worktree's uncommitted lines and untracked files) is on `main`. Exit 1 lists what is missing. | +| `run_gates.sh` | the repo's toolchain, `cargo-deny`, the wasm32 and thumbv7em targets | Runs the lint, build and test gates `.github/workflows/ci.yml` runs (not `bench`, `fuzz`, `coverage` or `libretro-cross`), one cargo job at a time, and prints one PASS / FAIL / SKIP line per gate with a log per gate. `--only ` selects gates, `--no-roms` skips the ROM suites. The commercial suite is SKIP, never PASS, when no dumps are staged. | +| `branch_contained.py` | stdlib only | Before deleting a squash-merged branch or a worktree: checks that every line the branch added (committed from the merge base, plus a worktree's uncommitted lines) equals a line of the same file on `main`, that every binary file it changed matches `main`'s, and that a worktree's untracked files exist on `main` with the same contents. Exit 1 lists what is missing. | `run_gates.sh` is a transcription, so it drifts: when `ci.yml` gains a gate, add it there in the same change. Its value is the feature combinations: most of its @@ -90,6 +90,9 @@ gates name a feature set, target or crate that an everyday `branch_contained.py` replaces a `wtcheck.py` that checked only a worktree's uncommitted lines, which would have passed a branch with unmerged commits. Its line test is a heuristic: a line moved to another file reads as missing (safe), -so read every MISSING line before deleting. Tested against a synthetic squash -merge: 0 missing after the merge, and 1 missing line plus 1 untracked file once -an unmerged commit and a stray file were added. +so read every MISSING line before deleting. Tested against a synthetic +repository: 0 missing after a squash merge; and, each reported, a line that is +only a SUBSTRING of a line on `main` (`return value;` against +`return value + 1;`), an unmerged binary file, and an untracked file whose path +exists on `main` with different contents. The first version passed all three; +review on #604 and #605 found them. diff --git a/scripts/release-automation/branch_contained.py b/scripts/release-automation/branch_contained.py index a9a97883..e65ec221 100755 --- a/scripts/release-automation/branch_contained.py +++ b/scripts/release-automation/branch_contained.py @@ -9,20 +9,28 @@ `main` has moved on since the merge. What is actually wanted is "every line this branch added is present in that file on `main`", which survives both. -It checks three sources of added lines: +It checks four things: * the branch's committed lines: `git diff
...` (from the merge base, so lines `main` added meanwhile do not count against the branch); * in a worktree, its uncommitted lines: `git diff HEAD`; -* in a worktree, its untracked files: each must exist on `main` at the same path. - -A line counts as present when its stripped text occurs anywhere in the same -path on `main`. Lines shorter than 8 characters (braces, `end`, blank-ish +* binary files changed either way, which carry no lines: the branch's (or the + worktree's) blob must equal `main`'s at the same path; +* in a worktree, its untracked files: each must exist on `main` at the same + path WITH THE SAME CONTENTS. + +A line counts as present when its stripped text EQUALS a stripped line of the +same path on `main` (a substring test would let `return value` pass against +`return value + 1`). Lines shorter than 8 characters (braces, `end`, blank-ish lines) are skipped as uninformative. This is a containment heuristic, not a proof: a line moved to a different file reads as missing (a false alarm, safe), and a line that also occurs elsewhere in the file reads as present. Read every MISSING line before deleting anything. +The exact-line, binary and untracked-contents rules were added after +`CodeRabbit` on #604 and #605 found the first version weaker than this +docstring claimed. + The v3.1.0 and v3.2.0 cleanups used an earlier form of this, which checked only uncommitted lines. It was enough for those worktrees, but it would have passed a branch with unmerged commits. @@ -37,6 +45,7 @@ from __future__ import annotations import argparse +import os import subprocess import sys from collections import defaultdict @@ -49,6 +58,17 @@ def git(repo: str, *args: str, check: bool = True) -> subprocess.CompletedProces return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True, check=check) +def blob(repo: str, rev_path: str) -> bytes | None: + """The bytes of `rev:path`, or None when it does not exist there.""" + r = subprocess.run(["git", "-C", repo, "show", rev_path], capture_output=True, check=False) + return r.stdout if r.returncode == 0 else None + + +def binary_paths(numstat: str) -> list[str]: + """Paths `git diff --numstat` reports as binary (`--path`).""" + return [ln.split("\t", 2)[2] for ln in numstat.splitlines() if ln.startswith("-\t-\t")] + + def added_lines(diff: str) -> dict[str, list[str]]: """Map each `+++ b/` in a unified diff to its added, stripped lines.""" out: dict[str, list[str]] = defaultdict(list) @@ -76,8 +96,16 @@ def main() -> int: print("detached HEAD: pass --branch", file=sys.stderr) return 2 committed = git(a.path, "diff", f"{a.main}...{branch}").stdout + committed_bin = binary_paths( + git(a.path, "diff", "--no-renames", "--numstat", f"{a.main}...{branch}").stdout + ) checked_out = git(a.path, "branch", "--show-current").stdout.strip() == branch uncommitted = git(a.path, "diff", "HEAD").stdout if checked_out else "" + uncommitted_bin = ( + binary_paths(git(a.path, "diff", "--no-renames", "--numstat", "HEAD").stdout) + if checked_out + else [] + ) untracked = ( git(a.path, "ls-files", "--others", "--exclude-standard").stdout.split("\n") if checked_out @@ -94,23 +122,45 @@ def main() -> int: total = missing = 0 for path, texts in sorted(lines.items()): on_main = git(a.path, "show", f"{a.main}:{path}", check=False).stdout + main_lines = {ln.strip() for ln in on_main.splitlines()} for text in texts: total += 1 - if text not in on_main: + if text not in main_lines: missing += 1 if missing <= SHOW_MISSING: print(f" MISSING {path}: {text[:110]}") if missing > SHOW_MISSING: print(f" ... and {missing - SHOW_MISSING} more") + # Binary files: the branch's blob (or, for an uncommitted change, the file + # on disk) must equal main's at the same path. + binaries = 0 + for path in committed_bin: + binaries += 1 + if blob(a.path, f"{branch}:{path}") != blob(a.path, f"{a.main}:{path}"): + missing += 1 + print(f" MISSING (binary) {path}") + for path in uncommitted_bin: + binaries += 1 + disk = os.path.join(a.path, path) + here = open(disk, "rb").read() if os.path.isfile(disk) else None + if here != blob(a.path, f"{a.main}:{path}"): + missing += 1 + print(f" MISSING (binary, uncommitted) {path}") + absent = 0 for path in filter(None, untracked): - if git(a.path, "cat-file", "-e", f"{a.main}:{path}", check=False).returncode != 0: + with open(os.path.join(a.path, path), "rb") as f: + here = f.read() + there = blob(a.path, f"{a.main}:{path}") + if there != here: absent += 1 - print(f" UNTRACKED, NOT ON {a.main}: {path}") + what = "NOT ON" if there is None else "DIFFERS FROM" + print(f" UNTRACKED, {what} {a.main}: {path}") print( - f"{branch}: {total} added lines checked, {missing} not found on {a.main}; " + f"{branch}: {total} added lines and {binaries} binary files checked, " + f"{missing} not found on {a.main}; " f"{len([u for u in untracked if u])} untracked files, {absent} not on {a.main}" ) return 0 if missing == 0 and absent == 0 else 1 diff --git a/scripts/release-automation/run_gates.sh b/scripts/release-automation/run_gates.sh index 1492687e..1505abc4 100755 --- a/scripts/release-automation/run_gates.sh +++ b/scripts/release-automation/run_gates.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash -# run_gates.sh — run every Rust gate CI runs, locally, one cargo job at a time, -# and print one PASS/FAIL line per gate. +# run_gates.sh — run the lint, build and test gates CI runs, locally, one cargo +# job at a time, and print one PASS/FAIL line per gate. +# +# Not transcribed: ci.yml's `bench` (a relative frame-time A/B against the PR +# base), `fuzz` (nightly cargo-fuzz, its own workspace), `coverage` +# (cargo-llvm-cov with a 70% floor) and `libretro-cross` (cross toolchains); +# CI runs those. (Its first header said "every Rust gate", which was not true; +# CodeRabbit on #604.) # # Why this exists: CI's gate set is spread across `.github/workflows/ci.yml`, # and the invocations that catch real defects are the ones a habitual local @@ -36,7 +42,11 @@ only="" roms=1 while [ $# -gt 0 ]; do case "$1" in - --only) only="${2:-}"; shift 2 ;; + # `shift 2` with one argument left fails WITHOUT shifting, and without + # `set -e` the loop would then spin forever (CodeRabbit on #605). + --only) + [ $# -ge 2 ] || { echo "run_gates.sh: --only needs a regex" >&2; exit 2; } + only="$2"; shift 2 ;; --no-roms) roms=0; shift ;; -h|--help) sed -n '2,/^# Exit status/p' "$0"; exit 0 ;; *) echo "run_gates.sh: unknown argument '$1'" >&2; exit 2 ;; From d53644fdd7896e42e98c463fad5794888c265007 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:01:24 -0400 Subject: [PATCH 084/104] docs: the review round's records -- changelog, tickets and stale counts From #604's review and this round's fixes. - CHANGELOG [3.2.0]: the Bandai FCG save-state break in Breaking; in Fixed, the mobile FDS save fixes, the netplay timeout frame, the Datach scan and window, the save-file mode, and the corrupt-state and impossible-image refusals. The release notes carry the break in their lead and Breaking list. - to-dos/ROADMAP.md: seven tickets for the deferred findings (T-VRC4-CHRRAM-BANKING, T-HISTORY-DISK-SWAP, T-MAPPER-PEEK-DEDUP, T-DATACH-EXT-EEPROM-BATTERY, T-IOS-DURABLE-WRITE, T-IOS-FDS-PERIODIC, T-XS-M185-CHRCS for the MiSTer core). - Mapper counts the cut left at 191: the libretro .info, DOCUMENTATION_ INDEX, architecture.md, dev/TESTING.md, the user guide's compatibility page and to-dos/README.md (208). docs/mappers.md's BestEffort sentence said 26 and listed 111 (which left at v2.9.6) without 194 and 230; it now says 27 and lists tier.rs's BEST_EFFORT_IDS exactly. Dated mentions ("191 at v3.0.0") are history and stay. - docs/ios.md: "All of this Swift compiles" contradicted the same paragraph's "the Swift half is not yet compiled"; it now says the cited run compiled the v2.9.7 Swift, before the v3.2.0 FDS persistence. The upstream libretro-super .info is synced after the merge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 3 +- CHANGELOG.md | 30 ++++++++++++++++++- .../rustynes-libretro/rustynes_libretro.info | 2 +- docs/DOCUMENTATION_INDEX.md | 2 +- docs/architecture.md | 2 +- docs/dev/TESTING.md | 2 +- docs/ios.md | 7 +++-- docs/mappers.md | 13 ++++---- docs/user-guide/compatibility.md | 2 +- to-dos/README.md | 2 +- to-dos/ROADMAP.md | 7 +++++ 11 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md index d8e466d8..e3196b7b 100644 --- a/.github/release-notes/v3.2.0.md +++ b/.github/release-notes/v3.2.0.md @@ -1,10 +1,11 @@ # RustyNES v3.2.0 — "Confluence" -Every v3.2.0 branch, both repositories, brought into one release. It adds 17 mapper families (191 to 208), among them the Bandai Datach with its barcode reader, and five more families are Curated on local-dump evidence. Three emulation defects are fixed: the VRC4's IRQ mode was read inverted, VRC2 mirroring had settings the chip does not have, and *Fire Hawk* lost its single-screen mirroring on an iNES 1.0 header. An AI-made remediation branch was audited change by change before anything was kept: its byte-identical speed-ups are kept, and measured against the tree it joined: the APU mixes 2-6% faster and an MMC3 read about 9% faster, while its larger claims did not hold and two cartridge reads got slower for a reason not yet found; a 47% audio slowdown it introduced was found and removed; four of the five features it broke are fixed and kept, and Android audio is back on its blocking write, with the non-blocking version a ticket. Netplay moves to protocol 8, libretro save states shrink from 260 KB to about 16 KB, and new save files are owner-only. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line and custom palettes. **Movies and netplay from v3.1.0 are refused.** +Every v3.2.0 branch, both repositories, brought into one release. It adds 17 mapper families (191 to 208), among them the Bandai Datach with its barcode reader, and five more families are Curated on local-dump evidence. Three emulation defects are fixed: the VRC4's IRQ mode was read inverted, VRC2 mirroring had settings the chip does not have, and *Fire Hawk* lost its single-screen mirroring on an iNES 1.0 header. An AI-made remediation branch was audited change by change before anything was kept: its byte-identical speed-ups are kept, and measured against the tree it joined: the APU mixes 2-6% faster and an MMC3 read about 9% faster, while its larger claims did not hold and two cartridge reads got slower for a reason not yet found; a 47% audio slowdown it introduced was found and removed; four of the five features it broke are fixed and kept, and Android audio is back on its blocking write, with the non-blocking version a ticket. Netplay moves to protocol 8, libretro save states shrink from 260 KB to about 16 KB, and new save files are owner-only. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line and custom palettes. **Movies and netplay from v3.1.0 are refused, and so are v3.1.x save states of Bandai FCG games.** ## Breaking changes - **Movies and netplay from v3.1.0 are refused**: `EMULATION_EPOCH` rises from 3 to 4, because the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes change what the bus sees. Save states are not affected by the epoch. +- **v3.1.x save states of Bandai FCG games are refused** (mappers 16, 153, 157 and 159): their state now carries each serial EEPROM's protocol position, so a state taken mid-transaction resumes it instead of leaving the chip idle, which could make rewind, run-ahead and netplay rollback diverge. Other boards' states are unchanged. - **Netplay protocol 8 (`"RNE8"`)**: lost inputs are resent as one datagram per run of frames. A v3.1.x peer is refused as another emulator version. ## Fixed diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ef304c..c9acad73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ cycle-accurate core later replaced. from 3 to 4: the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes below change what the bus sees. A v3.1.x peer or movie is refused as another emulator version, naming its epoch. Save states are not affected by the epoch. +- **v3.1.x save states of Bandai FCG games are refused** (mappers 16, 153, + 157 and 159). Their state now carries each serial EEPROM's protocol + position as well as its contents, so a state taken in the middle of a + save-chip transaction resumes it; before, a restore left the chip idle, + and rewind, run-ahead and netplay rollback could diverge from a straight + run. Every other board's save states are unchanged. - **Netplay protocol 8 (`"RNE8"`).** Lost inputs are resent as one datagram per run of frames instead of one per frame. A v3.1.x peer cannot read that message, so its `"RNE7"` is refused as another emulator version (its epoch @@ -183,7 +189,12 @@ cycle-accurate core later replaced. its disk are saved when the app is backgrounded, a game is switched or closed, and the saved disk boots next time under the original game's identity, so its save states, RetroAchievements progress and settings are still found. Before, - a disk game's own save was lost on close. (The iOS half is not yet compiled.) + a disk game's own save was lost on close. On Android the disk is also checked + once a second during play, so a process killed in the background keeps the + last second's writes; on both, a disk write landing while the save is being + written is no longer lost, and on iOS reopening the running disk game no + longer boots the save from before its newest writes. (The iOS half is not + yet compiled locally.) - **The Android screen stays on during play** and sleeps again when paused or closed; with a gamepad it used to dim and sleep mid-game. - **Mapper 290 (*Asder 20-in-1*) boots.** Its 32 KiB PRG mode read the @@ -210,6 +221,23 @@ cycle-accurate core later replaced. register at the first write there, as nesdev's mapper 71 page recommends without a submapper. Before, only an NES 2.0 submapper 1 header enabled it, and the `$9000` write selected a PRG bank instead. +- **A peer timeout no longer runs a stray frame.** The netplay tick that ends + a session on a silent peer told the frontend the session was inactive, so it + ran one single-player frame on the failure tick. +- **A Datach barcode scan starts a fresh rewind and clip history**, as a Reset + does: a movie does not record the scan, so a clip spanning it could not + replay. The barcode window also closes when a game without a reader loads. +- **New save files are mode `0o600` exactly**, also under a umask that masks + owner bits (they were created `0o600 & ~umask`). +- **Save-state loads refuse a corrupt blob before changing anything** on the + Datach barcode reader (an impossible scan length), the FFE Super Magic Card + (an out-of-range FDS IRQ phase), mapper 116 (an MMC1 part refused after the + MMC3 part had been restored) and mappers 16/153/157/159 (an unknown + mirroring byte). A TXC 172 image with a four-screen header keeps its + mirroring across a save and load; it came back horizontal. +- **Mapper 252 refuses an image without CHR-ROM** (the board is CHR-ROM with + two CHR-RAM pages), and the FFE card refuses a trainer shorter than 512 + bytes instead of panicking. ## [3.1.0] - 2026-10-08 - "Bellwether" (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it) diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info index 847c29de..c60da92e 100644 --- a/crates/rustynes-libretro/rustynes_libretro.info +++ b/crates/rustynes-libretro/rustynes_libretro.info @@ -35,4 +35,4 @@ firmware0_path = "disksys.rom" firmware0_opt = "true" notes = "(!) disksys.rom (md5): ca30b50f880eb660a320674ed365ef7a|RustyNES requires this BIOS in the system directory to boot Famicom Disk System games." -description = "RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust, with an unsafe-free #![no_std] emulation core. Targeting the higan and Mesen2 accuracy tier, it runs from one master clock, splitting every CPU cycle into two halves and bringing the PPU up to date at each, rather than relying on per-game hacks. The core guarantees absolute determinism, making it an exceptional choice for advanced libretro features like run-ahead latency reduction, rollback netplay, and RetroAchievements (native RETRO_ENVIRONMENT_SET_MEMORY_MAPS support). With high compatibility spanning 191 mapper families, region-correct NTSC/PAL/Dendy timing, full Famicom Disk System support with in-game disk saves and labelled multi-side disk swap via the Disk Control interface, native Game Genie cheats, NES Zapper light-gun and Four Score support, and Vs. System arcade boards with their RGB palettes, coin and service inputs, RustyNES is ideal for users who seek reference-grade accuracy paired with modern stability." +description = "RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust, with an unsafe-free #![no_std] emulation core. Targeting the higan and Mesen2 accuracy tier, it runs from one master clock, splitting every CPU cycle into two halves and bringing the PPU up to date at each, rather than relying on per-game hacks. The core guarantees absolute determinism, making it an exceptional choice for advanced libretro features like run-ahead latency reduction, rollback netplay, and RetroAchievements (native RETRO_ENVIRONMENT_SET_MEMORY_MAPS support). With high compatibility spanning 208 mapper families, region-correct NTSC/PAL/Dendy timing, full Famicom Disk System support with in-game disk saves and labelled multi-side disk swap via the Disk Control interface, native Game Genie cheats, NES Zapper light-gun and Four Score support, and Vs. System arcade boards with their RGB palettes, coin and service inputs, RustyNES is ideal for users who seek reference-grade accuracy paired with modern stability." diff --git a/docs/DOCUMENTATION_INDEX.md b/docs/DOCUMENTATION_INDEX.md index 6a6a8fba..d0925f65 100644 --- a/docs/DOCUMENTATION_INDEX.md +++ b/docs/DOCUMENTATION_INDEX.md @@ -19,7 +19,7 @@ The core "spec" docs — kept in sync with the code in the same PR as a change. | [ppu-2c02.md](ppu-2c02.md) | 2C02 PPU — rendering pipeline, scrolling (Loopy), sprite evaluation, registers | | [apu-2a03.md](apu-2a03.md) | 2A03 APU — 5 channels, frame sequencer, non-linear mixer, DMC DMA | | [scheduler.md](scheduler.md) | PPU-master-clock lockstep scheduler (dot-resolution timing) | -| [mappers.md](mappers.md) | Mapper system — 191 families (51 Core / 109 Curated / 31 BestEffort tiers), banking, per-mapper IRQ, expansion audio | +| [mappers.md](mappers.md) | Mapper system — 208 families (51 Core / 130 Curated / 27 BestEffort tiers), banking, per-mapper IRQ, expansion audio | | [cartridge-format.md](cartridge-format.md) | iNES / NES 2.0 / FDS parsing | | [architecture.md](architecture.md) | Cross-cutting design (Bus owns mutable state, one-directional crate graph, determinism contract) | | [frontend.md](frontend.md) | The `rustynes` desktop app (winit + wgpu + cpal + egui), audio engine, pacing, run-ahead, video filters (NES_NTSC / CRT / custom `.pal`), input devices (Power Pad, turbo, input-display overlay), per-game mirroring database, debugger devtools (trace logger, event viewer), Lua Script console | diff --git a/docs/architecture.md b/docs/architecture.md index 32796fb3..c83314b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,7 @@ crates/ ├── rustynes-cpu/ # Ricoh 2A03 CPU (6502 + interrupt logic). No PPU/APU deps. ├── rustynes-ppu/ # 2C02 PPU. Depends on rustynes-mappers (for CHR/nametable bus). ├── rustynes-apu/ # 2A03 APU. Depends on rustynes-cpu only for shared DMC DMA hooks. -├── rustynes-mappers/ # Cartridge + mapper trait + 191 mapper families. +├── rustynes-mappers/ # Cartridge + mapper trait + 208 mapper families. ├── rustynes-frontend/ # The rustynes binary: winit + wgpu + cpal + egui. └── rustynes-test-harness/ # Test ROM runner, golden-master comparator, screenshot diff. ``` diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md index 1be15940..9e676bfb 100644 --- a/docs/dev/TESTING.md +++ b/docs/dev/TESTING.md @@ -22,7 +22,7 @@ RustyNES employs a comprehensive testing strategy combining unit tests, integrat - **AccuracyCoin 146/146 (100.00%)** since v3.1.0, on the catalog re-synced to upstream `f5f41dc2` (146 scored tests); the 144/144 reported from v2.6.18 to v3.0.1 was overstated, because the older ROM's `Misaligned OAM behavior` fail path fell through to a pass and hid a real failure. It read 144/144 from v2.6.18, on the catalog re-synced at v2.6.17 (144 assigned tests). It read 139/139 before the v2.0.1 re-sync grew it to 141, and 141/141 from v2.0.3 - **Unit test coverage** for all components - **Integration tests** for component interactions -- **Regression tests** for mapper edge cases (191 mapper families) +- **Regression tests** for mapper edge cases (208 mapper families) - **Game compatibility matrix** validation > `docs/STATUS.md` is the single source of truth for per-suite pass counts and diff --git a/docs/ios.md b/docs/ios.md index 0b22544f..5c0a9665 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -425,9 +425,10 @@ movies and Lua are refused on a cabinet. **From v3.2.0 FDS disk writes persist** paths, and the next load boots it through `bootSavedDisk`, which keeps the pristine disk's identity. The Swift half is not yet compiled (no macOS in that change); the Rust bridge and the Android half are tested. Settings > -Controls also gains **Cancel opposite directions** (default on). **All of this -Swift compiles** (the iOS workflow's Simulator build, most recently run -37242884341 on `c0b04195`) **and none of it has run on a device**; the checks are +Controls also gains **Cancel opposite directions** (default on). **The Swift +from v2.9.7 compiles** (the iOS workflow's Simulator build, run 37242884341 on +`c0b04195`, which predates the v3.2.0 FDS persistence) **and none of it has run +on a device**; the checks are rows T5-T7, T10 and T12 of `docs/mobile-v2.9.3-run-sheet.md`. **The game database's load-time corrections (v2.9.8).** The shared bridge now diff --git a/docs/mappers.md b/docs/mappers.md index 40286ec6..0a4dfacf 100644 --- a/docs/mappers.md +++ b/docs/mappers.md @@ -1012,14 +1012,15 @@ BestEffort families to Curated: each has a **cleanly-booting** staged commercial-ROM dump (57 already in `tests/roms/external/` + 29 sourced from GoodNES v3.23b) wired into a byte-identity boot-snapshot oracle in `external_extended.rs` (ADR 0011), taking accuracy-gated coverage from 60 → 146. -The remaining **26** BestEffort families have no cleanly-booting redistributable -dump — the 16 NES 2.0 high-id boards +The remaining **27** BestEffort families (`tier.rs`, `BEST_EFFORT_IDS`) stay +register-decode and save-state unit-tested only — the 16 NES 2.0 high-id boards (268/286/289/290/299/301/303/305/306/312/320/336/348/349/366/513, which GoodNES v3.23b's iNES-1.0 headers cannot encode); 8 boards with no matching cart -(29/39/81/104/174/179/238/261); and 2 boards whose only dump jams at boot -(50 SMB2j FDS-conversion, and 111, whose "Ninja Ryukenden" dump turned out at -v2.9.6 to be a different board; GTROM itself is now Curated) — and stay register-decode -and save-state unit-tested only. To keep that long tail from silently rotting, +(29/39/81/104/174/179/238/261); 50, whose only dump (the SMB2j FDS-conversion) +jams at boot; and 2 notes-only boards whose unstated choices were never tested +against an alternative (194, and 230 since v3.2.0). 111 left the list at v2.9.6 +(its "Ninja Ryukenden" dump turned out to be a different board; GTROM is +Curated). To keep that long tail from silently rotting, the **v2.1.0 "Fathom" F3.1 CI boot-smoke sweep** (`crates/rustynes-test-harness/tests/v21_best_effort_sweep.rs`) exercises the full parse -> construct -> dispatch -> run-loop integration for **every** diff --git a/docs/user-guide/compatibility.md b/docs/user-guide/compatibility.md index 6a22ef33..f338b828 100644 --- a/docs/user-guide/compatibility.md +++ b/docs/user-guide/compatibility.md @@ -19,7 +19,7 @@ documented in `docs/cartridge-format.md`. ## Supported mappers -iNES mapper numbers handled (**191 mapper families**; the table below lists the +iNES mapper numbers handled (**208 mapper families**; the table below lists the most common; the most popular set covers well over 95% of the licensed library). Mappers are tracked in three accuracy tiers — Core, Curated, and BestEffort — so the breadth count is honest about which families are diff --git a/to-dos/README.md b/to-dos/README.md index 5e47cffe..1baca7a2 100644 --- a/to-dos/README.md +++ b/to-dos/README.md @@ -12,7 +12,7 @@ This directory holds the phase-and-sprint development history that produced RustyNES v1.0.0 (the cycle-accurate production core) and the long line of feature/platform releases built on top of it. The phases below are **delivered** — RustyNES ships at v3.1.0 with a cycle-accurate core -(AccuracyCoin 146/146), **191 mapper families**, FDS, Vs./PC10, rollback netplay, +(AccuracyCoin 146/146), **208 mapper families**, FDS, Vs./PC10, rollback netplay, RetroAchievements, TAS movie tooling, the performance + desktop-UX shell, the full v1.1.0 → v1.7.x feature set (Lua scripting, visual filters, the studio / TAS-tooling / debugger-depth suite, the writable/programmable "Forge" tools, diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index bd6fd1e3..d4f5d63b 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1488,6 +1488,13 @@ and the sibling's integration. Slots are proposals for the maintainer. | `T-MISTER-VRAM-M10K` | The 2 KiB nametable is 16,384 register bits; three read sites and a write need a port plan before it can move to block RAM without a latency change | RTL-R2-02 | a MiSTer F-phase release | | `T-MISTER-OFFDIE-CHRRAM` | Off-die, a `$2007` CHR-RAM read soon after a write returns `$00` in simulation (pre-existing; `mapper13cprom206`, `mapper119tqrom230` are XFAILs) | sibling `docs/HARDWARE_TESTING.md` | v3.3.0 | | `T-PERF-MAPPER-READ-DRIFT` | MMC5 and FME-7 `cpu_read` are 4.7% and 6.1% slower than at `c922ed40` (two runs each) with neither function changed. Find the cause (disassembly, alignment) before believing any later mapper-dispatch A/B on those boards, and measure whether a real MMC5 or FME-7 frame moves | [`docs/performance.md`](../docs/performance.md), "v3.2.0 import" | v3.3.0 | +| `T-VRC4-CHRRAM-BANKING` | A CHR-RAM VRC2/VRC4 image writes pattern bytes unbanked (`addr % len`) but reads them banked (`chr_offset`), so it cannot read back what it wrote. Find which form a CHR-RAM VRC board takes, then red test and fix (an emulation change) | #608's review (m252) | v3.3.0 | +| `T-HISTORY-DISK-SWAP` | An FDS disk swap, like a Datach scan (cleared since #604's review), is input a `.rnm` does not record; clear the history timeline on it too | #604's review | v3.3.0 | +| `T-MAPPER-PEEK-DEDUP` | About 60 boards carry a `ppu_read` body identical to their `ppu_peek`; have `ppu_read` call `ppu_peek` where it has no side effect, with a byte-identity check and an `ab_check.sh` A/B (the PPU hot path) | #604's review | a performance release | +| `T-DATACH-EXT-EEPROM-BATTERY` | The Datach cartridge's own X24C01 is not persisted across sessions: `Mapper::sram` exposes one slice. Needs a battery-file format and host persistence on every frontend | #604's review | v3.4.0 | +| `T-IOS-DURABLE-WRITE` | iOS persistence uses `.atomic` writes with no `fsync` of the file or its directory (FDS saves, battery, overrides, movies, HD packs, palettes): one shared durable-write helper, the Swift counterpart of the desktop's `atomic_write.rs` | #605's review | v3.6.0 | +| `T-IOS-FDS-PERIODIC` | iOS flushes an FDS disk only on backgrounding and game switches; the battery timer exists only for battery games. A once-a-second disk check like Android's (added in #605's review) | #605's review | v3.6.0 | +| `T-XS-M185-CHRCS` | The MiSTer core's extra-sprite port ignores mapper 185's CHR chip-select (visible only with the >8-sprite option, CHR disabled and rendering on). Match the oracle's behaviour by a black-box stimulus first | sibling #59's review | a MiSTer F-phase release | | `T-SURVEY-UNTICKETED` | Triage the 25 items of the line plan's oracle survey that no plan, ticket or CHANGELOG entry names (ACC-11/12/14, AV-01..03, CI-03/06, CR-04/05, DOC-10, LR-01/05/06/07, MAP-04/06, NET-05, PERF-01..03, PROV-01..04; PROV-01 shipped as `T-NTSC-PROVENANCE` under its ticket name): ticket each, or record why not. The survey lived only in session scratch until v3.2.0 | [`plans/research/v3.1-line-survey-oracle-backlog.md`](plans/research/v3.1-line-survey-oracle-backlog.md) | v3.3.0 | ## Open questions blocking planning From 7d2bf0a6390cc75865d8137d444be72b7cfb8883 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:03:35 -0400 Subject: [PATCH 085/104] fix(mappers): refuse an EEPROM state the chip cannot reach, and mask reads The commit-time security review of 10d0b027 found a gap in that commit's validation: `check_lines` checked the mode tags, counter and line levels, but not the word address against the chip. Two read paths index `mem` by `addr` unmasked (the ChipAddress and the ack-to-read arms), so a crafted save state giving an X24C01 (128 bytes) a word address of $80 or more, or putting it in the 24C02-only ChipAddress phase, would make the next clocked read index past `mem` and panic: a DoS from an untrusted file. - `check_lines` is now a method of the chip: it also refuses `addr >= mem.len()`, and the ChipAddress mode (current or next) on an X24C01, which has no device-select byte and never enters it. - Defence in depth: the two unmasked reads use `addr & addr_mask()`, as the write and the third read already did. In any reachable state `addr` is within the mask, so this changes no behaviour. Test `an_x24c01_state_it_cannot_reach_is_refused`: address $80, and ChipAddress as the current and the next mode, each refused with the board unchanged; mutation (the address check disabled) fails it. Mapper crate 1,086 tests pass; clippy -D warnings. Both edits are inside the file's Mesen2-derived EEPROM region; its `// Provenance:` header is unchanged and the edits are RustyNES's own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../rustynes-mappers/src/m016_bandai_fcg.rs | 55 +++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs index fe0eafd3..6ef271aa 100644 --- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs +++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs @@ -310,7 +310,13 @@ impl Eeprom { /// Check a [`Self::save_lines`] record without changing anything, so a /// caller can validate every part of a state before restoring any of it. - fn check_lines(b: &[u8]) -> Result<(), MapperError> { + /// + /// It refuses what THIS chip cannot reach as well as what no chip can: the + /// read paths index `mem` by `addr` unmasked, so an X24C01 (128 bytes) + /// restored with a word address of `$80` or more, or in the 24C02-only + /// `ChipAddress` mode, would index past its memory and panic (the + /// commit-time security review of 10d0b027). + fn check_lines(&self, b: &[u8]) -> Result<(), MapperError> { let bad = |what: &str| Err(MapperError::Invalid(format!("EEPROM state: {what}"))); if b.len() != Self::LINES_LEN { return bad("wrong length"); @@ -324,6 +330,13 @@ impl Eeprom { if b[7] > 8 { return bad("bit counter above 8"); } + if usize::from(b[5]) >= self.mem.len() { + return bad("word address past the chip"); + } + let chip_address = I2cMode::ChipAddress.to_byte(); + if self.is_x24c01 && (b[2] == chip_address || b[3] == chip_address) { + return bad("a device-select phase on an X24C01, which has none"); + } Ok(()) } @@ -454,7 +467,7 @@ impl Eeprom { } else { // 24C02: sequential read continues to the next byte. self.next_mode = I2cMode::Read; - self.data = self.mem[self.addr as usize]; + self.data = self.mem[(self.addr & self.addr_mask()) as usize]; } } } @@ -472,7 +485,7 @@ impl Eeprom { self.output = 1; if self.chip_addr & 0x01 != 0 { self.next_mode = I2cMode::Read; - self.data = self.mem[self.addr as usize]; + self.data = self.mem[(self.addr & self.addr_mask()) as usize]; } else { self.next_mode = I2cMode::Address; } @@ -709,7 +722,7 @@ impl BandaiFcg { return Err(short()); } if let Some(ext) = self.ext_eeprom.as_ref() { - Eeprom::check_lines(&tail[1 + ext.mem.len()..fixed])?; + ext.check_lines(&tail[1 + ext.mem.len()..fixed])?; } self.ext_scl = tail[0] != 0; if let Some(ext) = self.ext_eeprom.as_mut() { @@ -1131,7 +1144,7 @@ impl Mapper for BandaiFcg { }; if let Some(ee) = self.eeprom.as_ref() { let at = 18 + ee.mem.len(); - Eeprom::check_lines(&data[at..at + Eeprom::LINES_LEN])?; + ee.check_lines(&data[at..at + Eeprom::LINES_LEN])?; } if self.variant == FcgVariant::Datach { self.load_datach_tail(datach_tail)?; @@ -1444,6 +1457,38 @@ mod tests { /// unknown mode tag, a counter past 8 or a line above 1 is refused, and the /// board keeps its state. So is an unknown mirroring byte, which used to /// return Err only after the banks had been restored (`CodeRabbit` on #607). + /// A state the X24C01 could never be in is refused: a word address past + /// its 128 bytes, or the 24C02-only device-select phase. Either would make + /// the next clocked read index past `mem` and panic. + #[test] + fn an_x24c01_state_it_cannot_reach_is_refused() { + let variant = FcgVariant::Lz93d50_24c01; + let m = BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap(); + let good = m.save_state(); + let lines = 18 + m.eeprom.as_ref().unwrap().mem.len(); + let mut other = + BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap(); + let before = other.save_state(); + for (at, value, what) in [ + (lines + 5, 0x80, "word address $80"), + ( + lines + 2, + I2cMode::ChipAddress.to_byte(), + "ChipAddress mode", + ), + ( + lines + 3, + I2cMode::ChipAddress.to_byte(), + "ChipAddress next mode", + ), + ] { + let mut bad = good.clone(); + bad[at] = value; + assert!(other.load_state(&bad).is_err(), "{what} refused"); + assert_eq!(other.save_state(), before, "{what}: nothing restored"); + } + } + #[test] fn a_corrupt_state_is_refused_before_anything_changes() { let variant = FcgVariant::Lz93d50_24c02; From 4d8f0633c635e4bdcced1123456c134bd051c3e6 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:29:22 -0400 Subject: [PATCH 086/104] fix(android): let only the running controller write an FDS save Review on #604 found two ways an OUTGOING controller's disk image reached a save file it no longer owned, both left by b40859f2: - The game loop captured `ctrl` at the top of an iteration and read `emulator.romSha` later. publishRom swaps both on the main thread with no fence, so a ROM switch in between paired the old disk with the NEW game's key, and the once-a-second flush wrote it there (Cursor). - A flush the outgoing controller had queued, or publishRom's own outgoing flush on Dispatchers.IO, could run after the incoming controller had written the same file when the same disk was reopened, putting the older image back. @Synchronized serialised the writes but did not order them by controller (CodeRabbit). Both are writes by a controller that is not the key's current owner. New `SaveOwners` (pure Kotlin, so it is unit-testable without the JNI bridge): each key has at most one owner, compared by identity (`===`); `write` runs its block only for the owner; `handOver` is one locked step, the outgoing controller's final flush (if it still owns its key) and then the incoming claim; `release` ignores a former owner. FdsSaves routes every write through it. publishRom now runs the handover on every load, the first included, since the first load must claim. A refused write runs nothing, so the dirty flag stays set and the next flush retries it: an incoming controller flushing before its claim lands loses nothing. Closing a ROM and the screen's teardown call the new EmulatorHandle.releaseSaves, a final write plus release. Also from #604 (CodeRabbit): the loop scheduled a flush only when the disk was dirty, but flushIfDirty clears the flag before writing, so a failed write was retried only by the next lifecycle flush. `needsFlush` includes the retry set, now a ConcurrentHashMap key set because the game loop reads it without the lock a disk write holds. Verification: SaveOwnersTest, 5 cases written from the two reports (Cursor's pairing, CodeRabbit's reopen ordering, first claim, release, stale release); red as a compile failure before the class existed, then 5/5. Mutation: the owner check in `write` replaced with `false` -> 4 of 5 fail (CAUGHT); restored and compared byte for byte. Both flavours compile (compileFossDebugKotlin, compilePlayDebugKotlin); all 25 unit tests pass. NOT verified: the race itself on a device; the emulator ordering is argued from the code, not reproduced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 6 +- .../com/doublegate/rustynes/MainActivity.kt | 95 +++++++++++++++---- .../com/doublegate/rustynes/SaveOwners.kt | 62 ++++++++++++ .../com/doublegate/rustynes/SaveOwnersTest.kt | 84 ++++++++++++++++ docs/android.md | 15 ++- 5 files changed, 240 insertions(+), 22 deletions(-) create mode 100644 android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt create mode 100644 android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c9acad73..490cc010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,8 +191,10 @@ cycle-accurate core later replaced. save states, RetroAchievements progress and settings are still found. Before, a disk game's own save was lost on close. On Android the disk is also checked once a second during play, so a process killed in the background keeps the - last second's writes; on both, a disk write landing while the save is being - written is no longer lost, and on iOS reopening the running disk game no + last second's writes and a failed write is retried, and a game switch can no + longer write one game's disk into another game's save or an older copy of the + same disk over a newer one; on both, a disk write landing while the save is + being written is no longer lost, and on iOS reopening the running disk game no longer boots the save from before its newest writes. (The iOS half is not yet compiled locally.) - **The Android screen stays on during play** and sleeps again when paused or diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt index e7a2639a..75355234 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt @@ -579,6 +579,16 @@ class EmulatorHandle { romSha?.let { sha -> FdsSaves.flushIfDirty(context, ctrl, sha) } } + /** + * The session is ending (close, teardown): write the disk one last time and + * give up its key, so nothing writes that file until the next load claims + * it (v3.2.0, review on #604). Call before clearing [controller]. + */ + fun releaseSaves(context: Context) { + val ctrl = controller ?: return + romSha?.let { sha -> FdsSaves.close(context, ctrl, sha) } + } + /** Lowercase hex SHA-256 of the loaded ROM — the save-state directory key. */ var romSha: String? = null @@ -781,12 +791,57 @@ internal object FdsSaves { }.onFailure { android.util.Log.w("RustyNES", "saved FDS disk not used", it) } } - /** Disks whose last write failed: flushed again even though now clean. */ - private val retry = mutableSetOf() + /** + * Disks whose last write failed: flushed again even though now clean. A + * concurrent set because the game loop reads it ([needsFlush]) without the + * lock a disk write holds. + */ + private val retry: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() + + /** Which controller may write each key: see [SaveOwners] (review on #604). */ + private val owners = SaveOwners() + + /** + * Whether the periodic flush should call [flushIfDirty]: the disk changed, + * or its last write failed. Checking the dirty flag alone left a failed + * write unretried until the next lifecycle flush, because [flushIfDirty] + * clears the flag before writing (CodeRabbit on #604). + */ + fun needsFlush(ctrl: NesController, sha: String): Boolean = ctrl.diskIsDirty() || sha in retry + + /** + * A ROM switch, or the first load: write the outgoing disk one last time, + * then let the incoming controller write its key. One locked step, so a + * flush the outgoing controller queued earlier cannot land after the + * incoming one has written the same file (the same disk, reopened). + */ + fun handOver( + context: Context, + outSha: String?, + outCtrl: NesController?, + inSha: String?, + inCtrl: NesController?, + ) = owners.handOver(outSha, outCtrl, inSha, inCtrl) { + if (outSha != null && outCtrl != null) writeIfDirty(context, outCtrl, outSha) + } + + /** Closing a ROM: one last write, then nothing writes [sha] until the next load. */ + fun close(context: Context, ctrl: NesController, sha: String) = + owners.handOver(sha, ctrl, null, null) { writeIfDirty(context, ctrl, sha) } + + /** + * Write the disk if the game changed it and [ctrl] still owns [sha]. A + * refused call changes nothing (the dirty flag stays set), so a flush made + * by the incoming controller before its claim landed is simply retried by + * the next one. + */ + fun flushIfDirty(context: Context, ctrl: NesController, sha: String) { + owners.write(sha, ctrl) { writeIfDirty(context, ctrl, sha) } + } /** - * Write the disk if the game changed it, atomically ([android.util.AtomicFile]: - * a crash mid-write keeps the previous file). + * The write itself, atomically ([android.util.AtomicFile]: a crash + * mid-write keeps the previous file). Called only under [owners]' lock. * * The dirty flag is cleared BEFORE the image is copied. `diskIsDirty`, * `diskImageBytes` and `clearDiskDirty` are three separately locked bridge @@ -794,11 +849,9 @@ internal object FdsSaves { * in picture-in-picture), so clearing after the write discarded any disk * write that landed between the copy and the clear (Cursor on #605). A write * after the clear sets the flag again and is caught by the next flush; a - * failed file write is remembered in [retry] and flushed again. Synchronized - * because the game loop's periodic flush and the lifecycle flush both call it. + * failed file write is remembered in [retry] and flushed again. */ - @Synchronized - fun flushIfDirty(context: Context, ctrl: NesController, sha: String) { + private fun writeIfDirty(context: Context, ctrl: NesController, sha: String) { if (!ctrl.diskIsDirty() && sha !in retry) return ctrl.clearDiskDirty() val bytes = ctrl.diskImageBytes() @@ -904,14 +957,16 @@ private fun publishRom( emulator.romSha = prepared.sha emulator.romBytes = prepared.bytes emulator.battery = prepared.battery - if (outgoingCtrl != null) { - scope.launch(Dispatchers.IO) { - if (outgoingBattery != null) { - runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } - .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } - } - if (outgoingSha != null) FdsSaves.flushIfDirty(context, outgoingCtrl, outgoingSha) + scope.launch(Dispatchers.IO) { + if (outgoingCtrl != null && outgoingBattery != null) { + runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } + .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } } + // The outgoing disk's last write and the incoming controller's claim on + // its key, as one step (review on #604). Runs on every load, the first + // included: until the claim lands, the incoming disk's flushes are + // refused and retried, never written. + FdsSaves.handOver(context, outgoingSha, outgoingCtrl, prepared.sha, prepared.ctrl) } // Apply this game's remembered video filter (per-game DB), if any. prepared.filter?.let { f -> @@ -2342,6 +2397,7 @@ private fun EmulatorScreen( raSaveProgress() // v2.7.4 (AND-09): the final battery write before the controller goes. emulator.flushBattery(context) + emulator.releaseSaves(context) emulator.battery = null val ctrl = emulator.controller if (settings.raEnabled) runCatching { ctrl?.raUnloadGame() } @@ -2898,10 +2954,16 @@ private fun EmulatorScreen( // desktop flushes it every frame (Cursor on #605). Once a // second, like the battery above; flushIfDirty is synchronized // and clears the flag first, so a write during it is kept. + // `ctrl` was read at the top of this iteration and the key is + // read here, so after a ROM switch in between they name two + // games; FdsSaves refuses a controller that does not own the + // key, so that pair writes nothing (Cursor on #604). A failed + // write is retried here too, not only by the next lifecycle + // flush (CodeRabbit on #604). val fdsSha = emulator.romSha if (fdsSha != null && ++fdsFrame >= 60) { fdsFrame = 0 - if (ctrl.diskIsDirty()) { + if (FdsSaves.needsFlush(ctrl, fdsSha)) { launch(Dispatchers.IO) { FdsSaves.flushIfDirty(context, ctrl, fdsSha) } } } @@ -3113,6 +3175,7 @@ private fun EmulatorScreen( // Persist RA progress before tearing down the controller (ROM unload). raSaveProgress() emulator.flushBattery(context) + emulator.releaseSaves(context) emulator.battery = null emulator.controller = null } diff --git a/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt b/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt new file mode 100644 index 00000000..f317c127 --- /dev/null +++ b/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt @@ -0,0 +1,62 @@ +package com.doublegate.rustynes + +/** + * Which controller may write each SHA-keyed save file (v3.2.0, used by + * [FdsSaves]). + * + * An FDS disk is flushed from three places -- the game loop once a second, the + * lifecycle flush (onPause/onStop, close), and the outgoing flush on a ROM + * switch -- on three different threads, and each one names its controller and + * its key separately. Serializing the writes was not enough (review on #604): + * + * - the game loop captured its controller at the top of an iteration and read + * the ROM identity later, so a switch in between paired the OUTGOING disk + * with the INCOMING game's file (Cursor); + * - a flush queued by the outgoing controller could run after the incoming one + * had written the same file, when the same disk was reopened, and put the + * older image back (CodeRabbit). + * + * Both are writes by a controller that no longer owns the key. Here a key has + * at most one owner, a write by anyone else is refused, and a ROM switch is one + * locked step: the outgoing controller's final flush, then the incoming claim. + * A refused write leaves the controller's dirty flag set (the caller clears it + * only inside [write]), so nothing is lost by refusing a write that was in + * fact the incoming owner's, made before its claim landed: the next flush + * retries it. + * + * Identity, not equality: owners are compared with `===`, because two + * controllers for the same disk are equal in every way that matters except + * which one the game loop is running. + */ +internal class SaveOwners { + private val owners = HashMap() + + /** Run [block] only if [ctrl] owns [key]; true when it ran. */ + @Synchronized + fun write(key: String, ctrl: C, block: () -> Unit): Boolean { + if (owners[key] !== ctrl) return false + block() + return true + } + + /** + * Retire [outCtrl] from [outKey] (running [finalFlush] first if it still + * owns it) and give [inKey] to [inCtrl], with no write from either side + * able to land in between. Either side may be absent: the first load has + * nothing outgoing. + */ + @Synchronized + fun handOver(outKey: String?, outCtrl: C?, inKey: String?, inCtrl: C?, finalFlush: () -> Unit) { + if (outKey != null && outCtrl != null && owners[outKey] === outCtrl) { + finalFlush() + owners.remove(outKey) + } + if (inKey != null && inCtrl != null) owners[inKey] = inCtrl + } + + /** End [ctrl]'s ownership of [key]; a release by a former owner is ignored. */ + @Synchronized + fun release(key: String, ctrl: C) { + if (owners[key] === ctrl) owners.remove(key) + } +} diff --git a/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt b/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt new file mode 100644 index 00000000..09f382b6 --- /dev/null +++ b/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt @@ -0,0 +1,84 @@ +package com.doublegate.rustynes + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * v3.2.0: the fence that decides which controller may write a SHA-keyed FDS + * save. Review on #604 found two ways an OUTGOING controller's disk image + * reached a file it no longer owned: the game loop paired the controller it + * captured with the ROM identity it read later, across a ROM switch (Cursor), + * and a flush queued before the switch ran after the new controller had + * written the same file, on reopening the same disk (CodeRabbit). Both are + * writes from a controller that is not the file's current owner. + */ +class SaveOwnersTest { + private val a = "a".repeat(64) + private val b = "b".repeat(64) + + /** The first load claims its key; a controller that never claimed is refused. */ + @Test + fun only_the_claimant_may_write() { + val owners = SaveOwners() + val ctrl = Any() + assertFalse(owners.write(a, ctrl) { error("must not run") }) + owners.handOver(null, null, a, ctrl) { error("nothing outgoing") } + assertTrue(owners.write(a, ctrl) {}) + } + + /** Cursor's case: the old controller paired with the NEW game's key. */ + @Test + fun an_outgoing_controller_cannot_write_the_incoming_key() { + val owners = SaveOwners() + val old = Any() + val new = Any() + owners.handOver(null, null, a, old) {} + owners.handOver(a, old, b, new) {} + assertFalse(owners.write(b, old) { error("old image into the new game's file") }) + assertFalse(owners.write(a, old) { error("old no longer owns its key either") }) + assertTrue(owners.write(b, new) {}) + } + + /** + * CodeRabbit's case: the same disk reopened. The outgoing final flush runs + * inside the handover, before the claim, and a flush the old controller + * queued earlier is refused once the new one owns the key, so it can never + * land after the new controller's write. + */ + @Test + fun reopening_the_same_key_orders_the_final_flush_first() { + val owners = SaveOwners() + val old = Any() + val new = Any() + val log = mutableListOf() + owners.handOver(null, null, a, old) {} + owners.handOver(a, old, a, new) { log += "old final" } + assertTrue(owners.write(a, new) { log += "new" }) + assertFalse(owners.write(a, old) { log += "old stale" }) + assertEquals(listOf("old final", "new"), log) + } + + /** Closing a ROM releases the key, so nothing writes it until the next claim. */ + @Test + fun release_ends_ownership() { + val owners = SaveOwners() + val ctrl = Any() + owners.handOver(null, null, a, ctrl) {} + owners.release(a, ctrl) + assertFalse(owners.write(a, ctrl) { error("released") }) + } + + /** A release by a controller that no longer owns the key leaves the owner alone. */ + @Test + fun a_stale_release_is_ignored() { + val owners = SaveOwners() + val old = Any() + val new = Any() + owners.handOver(null, null, a, old) {} + owners.handOver(a, old, a, new) {} + owners.release(a, old) + assertTrue(owners.write(a, new) {}) + } +} diff --git a/docs/android.md b/docs/android.md index ea1ca2c6..6c94a5f2 100644 --- a/docs/android.md +++ b/docs/android.md @@ -287,10 +287,17 @@ The control bar gains **Disk A/B** (next side), **Track n/m** with **<** (NSF), **Coin** (any Vs. machine). A cabinet's save state is the core's `RVSD` container; netplay, movies and Lua are refused on it with `MobileException.DualSystem`. **From v3.2.0 a disk game's own save persists**: -when the game has written to its disk (`diskIsDirty`), every session-ending path -(background, ROM switch, close) writes `diskImageBytes` atomically (`AtomicFile`) -to `filesDir/fds-saves/.fds.sav`, the desktop's layout, clearing the -flag only once the write lands. The next load builds from the pristine image and +when the game has written to its disk (`diskIsDirty`), the game loop (once a +second) and every session-ending path (background, ROM switch, close) write +`diskImageBytes` atomically (`AtomicFile`) to +`filesDir/fds-saves/.fds.sav`, the desktop's layout. The flag is +cleared BEFORE the image is copied, so a disk write landing during the save sets +it again and the next flush catches it; a failed file write is retried by the +next once-a-second check. Each key has one owner (`SaveOwners`): only the +controller the loop is running may write its game's file, and a ROM switch is +one locked step, the outgoing disk's last write and then the incoming claim, so +no flush can put one game's disk in another's file or an older image over a +newer one when the same disk is reopened (`SaveOwnersTest`). The next load builds from the pristine image and calls `bootSavedDisk`, which boots the saved copy under the PRISTINE disk's identity, so the save, the states, RA progress and overrides keep one key (loading the saved file as the ROM would change it at the first disk write). A bad or From c6f0fe9aa650a9ebd6cfa91f9ad0f0188ad017a1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:30:01 -0400 Subject: [PATCH 087/104] perf(cosim): stream --fb-every-frame captures instead of holding them run_frames_capturing_each collected every frame's index framebuffer in one Vec and wrote it at the end. 8b8ecb4e capped only the initial reservation (600 frames), so memory still grew with --frames: about 120 KiB a frame, 2.4 GB for a 20,000-frame capture, and a run killed for it left no artifact at all (CodeRabbit on #604). Each frame is now encoded into one reused frame buffer and written through a BufWriter. The bytes are unchanged (frames in order, every pixel a little-endian u16, nothing between them). The final flush goes through `into_inner`, because dropping a BufWriter swallows a failed flush; a write error panics with the path, as the shared `write` helper does. Verification: - Byte identity: both recipes that use the flag (mapper4mmc3sprlim083, mapper119tqromsprlim238; 12 frames, the sibling's golden-recipes.tsv arguments) re-exported and cmp'd against the sibling's committed goldens: identical. - Memory: peak RSS 6.2 MB at 12 frames, 10.5 MB at 1,200 frames (147,456,000 bytes written); the Vec version would have held the whole 147 MB. - cosim fmt, clippy --all-targets --all-features -D warnings, tests (45 + 13 + 1) pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../src/bin/nes_golden_export.rs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs index e3d4a4b7..edffde36 100644 --- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs +++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs @@ -1148,12 +1148,23 @@ fn run_frames_with_optional_press( /// Refused with `--press-start` rather than combined with it: no recipe needs /// both, and an unexercised combination is a place a golden goes quietly wrong. fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 { + use std::io::Write as _; // `injection_error` has refused `--press-start` with this flag already. + // + // STREAMED, one frame at a time, through a buffered writer. The first + // version collected every frame in a Vec and wrote it at the end, so memory + // grew with `--frames` (about 120 KiB a frame: a 20,000-frame capture held + // 2.4 GB) and a run that was killed for it left no artifact at all + // (CodeRabbit on #604). Memory is now one frame's worth whatever the run + // length, and the file's bytes are the same: frames in order, each pixel a + // little-endian u16, nothing between them. + let path = suffixed(base, "index_fb_frames.bin"); + let fail = |e: std::io::Error| -> ! { panic!("write {}: {e}", path.display()) }; + let file = std::fs::File::create(&path).unwrap_or_else(|e| fail(e)); + let mut out = std::io::BufWriter::new(file); + let mut frame = Vec::with_capacity(INDEX_FB_LEN * 2); let mut calls = 0u64; - // Reserve for at most 600 frames (about 70 MiB) up front; `frames` comes - // from the command line, and a reservation sized by it alone could abort - // on a typo before a single frame ran. The Vec grows past this if asked. - let mut bytes = Vec::with_capacity(INDEX_FB_LEN * 2 * (args.frames as usize).min(600)); + let mut written = 0u64; for _ in 0..args.frames { calls += o.advance_frames(1); let fb = o.nes().index_framebuffer(); @@ -1162,11 +1173,19 @@ fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 { INDEX_FB_LEN, "unexpected index framebuffer length" ); + frame.clear(); for px in fb { - bytes.extend_from_slice(&px.to_le_bytes()); + frame.extend_from_slice(&px.to_le_bytes()); } + out.write_all(&frame).unwrap_or_else(|e| fail(e)); + written += frame.len() as u64; } - write(&suffixed(base, "index_fb_frames.bin"), &bytes); + // `into_inner` flushes and reports a failed final flush, which dropping a + // BufWriter would swallow; `sync_all` is not needed, `write` does not sync. + out.into_inner() + .map_err(std::io::IntoInnerError::into_error) + .unwrap_or_else(|e| fail(e)); + println!(" wrote {} ({written} bytes)", path.display()); calls } From fe2953952583f61776c1940fed63dc1d4708efa1 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:30:11 -0400 Subject: [PATCH 088/104] fix(scripts): compare untracked symlinks by target, not by opening them branch_contained.py opened every untracked path git listed. `git ls-files --others` lists symlinks too, so a dangling link raised FileNotFoundError, and a link to a directory raised IsADirectoryError, both as tracebacks instead of the documented verdict (CodeRabbit on #604). Git stores a symlink as a blob holding its target, so a link is now compared by os.readlink against main's blob at the same path, never followed. A path that is neither a link nor a regular file cannot be compared and is reported as such, counting against containment (exit 1) rather than passing. Reproduced first in a synthetic repository: an untracked dangling link -> traceback. After the fix: dangling, directory and file links not on main -> three "UNTRACKED, NOT ON main" lines, exit 1; a link equal to one committed on main -> 0 missing, exit 0; the same link retargeted -> "DIFFERS FROM main", exit 1. ruff check and ruff format --check clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/release-automation/branch_contained.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/release-automation/branch_contained.py b/scripts/release-automation/branch_contained.py index e65ec221..3fa456c1 100755 --- a/scripts/release-automation/branch_contained.py +++ b/scripts/release-automation/branch_contained.py @@ -150,8 +150,22 @@ def main() -> int: absent = 0 for path in filter(None, untracked): - with open(os.path.join(a.path, path), "rb") as f: - here = f.read() + # `git ls-files --others` lists symlinks too, and opening one that + # dangles or names a directory raised a traceback instead of a verdict + # (CodeRabbit on #604). Git stores a symlink as a blob holding its + # target, so a link is compared by `readlink`, never followed; anything + # that is neither a link nor a regular file cannot be compared at all, + # and is reported rather than passed. + full = os.path.join(a.path, path) + if os.path.islink(full): + here = os.fsencode(os.readlink(full)) + elif os.path.isfile(full): + with open(full, "rb") as f: + here = f.read() + else: + absent += 1 + print(f" UNTRACKED, NOT A FILE OR SYMLINK (cannot compare): {path}") + continue there = blob(a.path, f"{a.main}:{path}") if there != here: absent += 1 From ac32844331cd55f5c52920231ab7b2f42e88ce73 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:30:12 -0400 Subject: [PATCH 089/104] docs: v3.2.0 is the current release in to-dos/README; label S2's split Two stale statements from #604's review (CodeRabbit): - to-dos/README.md gave v3.2.0's 208 families beside "ships at v3.1.0" and named v3.1.0 "Bellwether" the current release; both now name v3.2.0 "Confluence" (2026-10-14), v3.1.0 kept in the release line. - The v3.2.0 plan's S2 status gave the tier split 51 / 114 / 26 with no date. It was true when S2 closed, so it stays, labelled as that point, beside the released 51 / 130 / 27 (after the misc families and the ADR 0011 tier ruling). Release anchor, release-state prose, contribution checklist and release-notes render audits pass; markdownlint (pre-commit) passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/README.md | 7 ++++--- to-dos/plans/v3.2.0-plan.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/to-dos/README.md b/to-dos/README.md index 1baca7a2..0d4374b0 100644 --- a/to-dos/README.md +++ b/to-dos/README.md @@ -11,7 +11,7 @@ lines named v1.8.8 until v2.7.6). This directory holds the phase-and-sprint development history that produced RustyNES v1.0.0 (the cycle-accurate production core) and the long line of feature/platform releases built on top of it. The phases below are -**delivered** — RustyNES ships at v3.1.0 with a cycle-accurate core +**delivered** — RustyNES ships at v3.2.0 with a cycle-accurate core (AccuracyCoin 146/146), **208 mapper families**, FDS, Vs./PC10, rollback netplay, RetroAchievements, TAS movie tooling, the performance + desktop-UX shell, the full v1.1.0 → v1.7.x feature set (Lua scripting, visual filters, the studio / @@ -26,8 +26,9 @@ production polish) → **`v1.1.0` "Scriptable" → `v1.2.0` "Curator" → `v1.3. "Studio" → `v1.7.0` "Forge"** (+ `v1.7.1`) **→ `v1.8.0` … `v1.8.8` "Atlas"** (the Android platform train), and from there through **`v2.0.0` "Timebase"** (the master-clock rewrite, ADR 0002), the v2.x accuracy, platform and audit lines, -through **`v3.0.0` "Cornerstone"** (2026-10-06, the API major), to the current -release, **`v3.1.0` "Bellwether"** (2026-10-08). +through **`v3.0.0` "Cornerstone"** (2026-10-06, the API major) and **`v3.1.0` +"Bellwether"** (2026-10-08), to the current release, **`v3.2.0` "Confluence"** +(2026-10-14). `to-dos/ROADMAP.md` and `docs/STATUS.md` carry the current state; this file's v1.8.x-era sections below are kept as history. Version markers in the phase bodies that read `v1.x`/`v2.x` are the inbound **engine's** prior lineage diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index e9d87581..5669e673 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -108,7 +108,7 @@ until the hardware release shipped.) The codename is asked at the cut. | Sprint | Deliverable | Gate | Status | | --- | --- | --- | --- | | 3.2-S1 | Items 1 and 4 (oracle families and the corpus) | rows 1, 4 | — | -| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26); 290 stays BestEffort (its boot fix is recorded above; its capture's top band is unexplained); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 done (KNOWN_BLANK 52 -> 41, every remaining entry triaged or previously documented) | +| 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26 at that point; 51 / 130 / 27 at the release, after the misc families and the ADR 0011 tier ruling); 290 stays BestEffort (its boot fix is recorded above; its capture's top band is unexplained); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 done (KNOWN_BLANK 52 -> 41, every remaining entry triaged or previously documented) | | 3.2-S3 | Item 5, the sibling options, first | row 5 | — | | 3.2-S4 | Items 6 and 7, the sibling mappers | rows 6, 7 | — | | 3.2-S5 | Item 8, then the seed sweep and the release cut | row 8; the sweep; both ladders | — | From c28cc6d9ac1518eba85ac9c1e1da61ef5b3b5d68 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:35:29 -0400 Subject: [PATCH 090/104] docs(mappers): the Datach tail comment named the old save-state version `save_datach_tail`'s rustdoc still said SAVE_STATE_VERSION "stays 2". 10d0b027 raised it to 3 for the EEPROM protocol state (Eeprom::save_lines), which the Datach tail also writes for its external X24C01. The tail did not need a bump of its own (the variant is new in v3.2.0, so no earlier blob of it exists); the comment now says that, and why the constant is 3 anyway, matching the constant's own rationale at line 100. Comment only; no code changes. Found by CodeRabbit's outside-diff note on #604. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- crates/rustynes-mappers/src/m016_bandai_fcg.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs index 6ef271aa..fdc8846c 100644 --- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs +++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs @@ -692,9 +692,10 @@ impl BandaiFcg { /// the external SCL level, the external X24C01's 128 bytes when the /// cartridge has one (fixed by the header, so not flagged), then the /// barcode reader ([`BarcodeReader::save`]). Added with the variant in - /// v3.2.0; no earlier blob of this variant exists, so - /// [`SAVE_STATE_VERSION`] stays 2 and the other variants' layouts are - /// unchanged. + /// v3.2.0, so no earlier blob of this variant exists. The tail itself + /// needed no version bump; [`SAVE_STATE_VERSION`] is 3 for a different + /// reason, the EEPROM protocol state every variant with an EEPROM now + /// saves (an earlier draft of this comment said it "stays 2"). fn save_datach_tail(&self, out: &mut Vec) { out.push(u8::from(self.ext_scl)); if let Some(ext) = self.ext_eeprom.as_ref() { From 9303c23552198b789f0a3c0abcbfdc030d88cdfd Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:37:47 -0400 Subject: [PATCH 091/104] docs(ios): the v3.2.0 Swift compiles; record the run that shows it docs/ios.md said the v3.2.0 FDS-persistence Swift was "not yet compiled", and the CHANGELOG entry said "not yet compiled locally". The iOS workflow was dispatched on this branch at d53644fd (run 38644695741): "Build the app (iOS Simulator, unsigned)" succeeded. The TestFlight steps were skipped because the repository has no signing secrets, so the run builds and uploads nothing. `git log d53644fd..HEAD -- ios/ crates/rustynes-ios crates/rustynes-mobile` is empty, so that build covers the Swift as it stands. Both now say it compiles and that neither half has run on a device; the v2.9.7 run (37242884341 on c0b04195) is kept as the older reference. Promised on #604's review thread 4275884276. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- CHANGELOG.md | 4 ++-- docs/ios.md | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 490cc010..43be3e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,8 +195,8 @@ cycle-accurate core later replaced. longer write one game's disk into another game's save or an older copy of the same disk over a newer one; on both, a disk write landing while the save is being written is no longer lost, and on iOS reopening the running disk game no - longer boots the save from before its newest writes. (The iOS half is not - yet compiled locally.) + longer boots the save from before its newest writes. (The iOS half compiles + in the iOS workflow's Simulator build; neither half has run on a device.) - **The Android screen stays on during play** and sleeps again when paused or closed; with a gamepad it used to dim and sleep mid-game. - **Mapper 290 (*Asder 20-in-1*) boots.** Its 32 KiB PRG mode read the diff --git a/docs/ios.md b/docs/ios.md index 5c0a9665..02f56feb 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -423,12 +423,12 @@ movies and Lua are refused on a cabinet. **From v3.2.0 FDS disk writes persist** (as on Android): a dirty disk is written atomically to `Application Support/RustyNES/fds-saves/.fds.sav` on the battery-flush paths, and the next load boots it through `bootSavedDisk`, which keeps the -pristine disk's identity. The Swift half is not yet compiled (no macOS in that -change); the Rust bridge and the Android half are tested. Settings > -Controls also gains **Cancel opposite directions** (default on). **The Swift -from v2.9.7 compiles** (the iOS workflow's Simulator build, run 37242884341 on -`c0b04195`, which predates the v3.2.0 FDS persistence) **and none of it has run -on a device**; the checks are +pristine disk's identity. Settings > Controls also gains **Cancel opposite +directions** (default on). **The v3.2.0 Swift compiles** (the iOS workflow's +Simulator build, run 38644695741 on `d53644fd`, which includes the FDS +persistence; no iOS or bridge source changed after it; the v2.9.7 Swift's +run was 37242884341 on `c0b04195`) **and none of it has run on a device**; +the Rust bridge and the Android half are tested. The device checks are rows T5-T7, T10 and T12 of `docs/mobile-v2.9.3-run-sheet.md`. **The game database's load-time corrections (v2.9.8).** The shared bridge now From 1c492c8c84f54a5b4ce21bd5716e2ba60dc25dde Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 18:46:01 -0400 Subject: [PATCH 092/104] fix(android): move FDS save ownership synchronously, keep a retiring right Corrects 4d8f0633. Its fence was right about WHO may write, but it claimed the incoming key late, inside the I/O job publishRom launches, after the running controller had already been swapped (Cursor on #604): - A close or pause in that window called FdsSaves for a controller that did not own its key yet; the write was refused, and nothing retried it once the claim landed, so a dirty disk could be lost. - Two quick switches launch two jobs on the multi-threaded IO dispatcher with no ordering. If they finished out of order on the same key, the retired controller was installed as owner, and the live session could not save at all. Ownership now moves in SaveOwners.transfer, called on the main thread in publishRom BEFORE emulator.controller is swapped. It does no I/O (a map update under the lock), so there is no moment in which the running controller does not own its key, and transfers are ordered the way the loads are. The outgoing controller gets a RETIRING right to its old key: its final flush can still run later on Dispatchers.IO (FdsSaves.finalFlush), and the right ends the moment the new owner first writes that key, so the same-disk reopen case still never writes an older image over a newer one. finalFlush drops the right afterwards (SaveOwners.finish), so the map does not keep a finished controller, and its native memory, reachable. close() is now owner-only and one locked step: final write, then nothing owns the key. Trap kept in mind: transfer is @Synchronized, so the main thread can wait behind a disk write in progress under the same lock (one AtomicFile write of one FDS image). The alternative, a lock-free claim, reopens the ordering problem this commit closes. Verification: SaveOwnersTest rewritten for the new API, 7 cases including Cursor's two (a close straight after a load writes; three quick switches on one key leave the LIVE controller owning it) and the earlier three (torn pair refused, reopen order old-final then new with stale old refused, stale close ignored). Red as a compile failure against the old API, then 7/7. Mutations, each restored and compared byte for byte: owner write no longer ends the retiring right -> 2 fail; transfer no longer grants it -> 2 fail (both CAUGHT). compileFossDebugKotlin and compilePlayDebugKotlin pass; all 27 unit tests pass. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .../com/doublegate/rustynes/MainActivity.kt | 60 +++++++----- .../com/doublegate/rustynes/SaveOwners.kt | 76 +++++++++----- .../com/doublegate/rustynes/SaveOwnersTest.kt | 98 +++++++++++++------ docs/android.md | 12 ++- 4 files changed, 161 insertions(+), 85 deletions(-) diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt index 75355234..e092404a 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt @@ -810,30 +810,32 @@ internal object FdsSaves { fun needsFlush(ctrl: NesController, sha: String): Boolean = ctrl.diskIsDirty() || sha in retry /** - * A ROM switch, or the first load: write the outgoing disk one last time, - * then let the incoming controller write its key. One locked step, so a - * flush the outgoing controller queued earlier cannot land after the - * incoming one has written the same file (the same disk, reopened). + * A ROM switch, or the first load: the incoming controller owns its key + * from here, and the outgoing one keeps a retiring right to its own until + * the new owner first writes it (see [SaveOwners]). No I/O, so call it on + * the main thread where the running controller is swapped; then run the + * outgoing disk's final write with [finalFlush]. */ - fun handOver( - context: Context, - outSha: String?, - outCtrl: NesController?, - inSha: String?, - inCtrl: NesController?, - ) = owners.handOver(outSha, outCtrl, inSha, inCtrl) { - if (outSha != null && outCtrl != null) writeIfDirty(context, outCtrl, outSha) + fun transfer(outSha: String?, outCtrl: NesController?, inSha: String?, inCtrl: NesController?) = + owners.transfer(outSha, outCtrl, inSha, inCtrl) + + /** + * The outgoing controller's last write after [transfer], then its retiring + * right is dropped. A no-op once the incoming owner has written the same + * key: the disk on file is then newer than this one. + */ + fun finalFlush(context: Context, ctrl: NesController, sha: String) { + owners.write(sha, ctrl) { writeIfDirty(context, ctrl, sha) } + owners.finish(sha, ctrl) } /** Closing a ROM: one last write, then nothing writes [sha] until the next load. */ fun close(context: Context, ctrl: NesController, sha: String) = - owners.handOver(sha, ctrl, null, null) { writeIfDirty(context, ctrl, sha) } + owners.close(sha, ctrl) { writeIfDirty(context, ctrl, sha) } /** - * Write the disk if the game changed it and [ctrl] still owns [sha]. A - * refused call changes nothing (the dirty flag stays set), so a flush made - * by the incoming controller before its claim landed is simply retried by - * the next one. + * Write the disk if the game changed it and [ctrl] may write [sha]. A + * refused call changes nothing (the dirty flag stays set). */ fun flushIfDirty(context: Context, ctrl: NesController, sha: String) { owners.write(sha, ctrl) { writeIfDirty(context, ctrl, sha) } @@ -953,20 +955,26 @@ private fun publishRom( val outgoingCtrl = emulator.controller val outgoingBattery = emulator.battery val outgoingSha = emulator.romSha + // The FDS save keys change hands HERE, on the main thread, before the + // running controller is swapped, and with no I/O (review on #604): a claim + // made later on an I/O thread left a window in which a close or a pause + // was refused, and two quick switches could finish out of order. + FdsSaves.transfer(outgoingSha, outgoingCtrl, prepared.sha, prepared.ctrl) emulator.controller = prepared.ctrl emulator.romSha = prepared.sha emulator.romBytes = prepared.bytes emulator.battery = prepared.battery - scope.launch(Dispatchers.IO) { - if (outgoingCtrl != null && outgoingBattery != null) { - runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } - .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + if (outgoingCtrl != null) { + scope.launch(Dispatchers.IO) { + if (outgoingBattery != null) { + runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } } + .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) } + } + // The outgoing disk's last write, through its retiring right: a + // no-op if the incoming controller has already written the same + // key (the same disk, reopened), whose image is then the newer one. + if (outgoingSha != null) FdsSaves.finalFlush(context, outgoingCtrl, outgoingSha) } - // The outgoing disk's last write and the incoming controller's claim on - // its key, as one step (review on #604). Runs on every load, the first - // included: until the claim lands, the incoming disk's flushes are - // refused and retried, never written. - FdsSaves.handOver(context, outgoingSha, outgoingCtrl, prepared.sha, prepared.ctrl) } // Apply this game's remembered video filter (per-game DB), if any. prepared.filter?.let { f -> diff --git a/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt b/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt index f317c127..936debd5 100644 --- a/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt +++ b/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt @@ -7,56 +7,84 @@ package com.doublegate.rustynes * An FDS disk is flushed from three places -- the game loop once a second, the * lifecycle flush (onPause/onStop, close), and the outgoing flush on a ROM * switch -- on three different threads, and each one names its controller and - * its key separately. Serializing the writes was not enough (review on #604): + * its key separately. Serializing the writes was not enough; review on #604 + * found three ways a write went to the wrong file or was lost: * * - the game loop captured its controller at the top of an iteration and read * the ROM identity later, so a switch in between paired the OUTGOING disk * with the INCOMING game's file (Cursor); - * - a flush queued by the outgoing controller could run after the incoming one - * had written the same file, when the same disk was reopened, and put the - * older image back (CodeRabbit). + * - a flush queued by the outgoing controller could run after the incoming + * one had written the same file, when the same disk was reopened, and put + * the older image back (CodeRabbit); + * - this class's first version claimed the key later, on an I/O thread, so a + * close or a pause before the claim was refused and never retried, and two + * quick switches could finish out of order and leave a retired controller + * owning the key, after which the live session could not save (Cursor). * - * Both are writes by a controller that no longer owns the key. Here a key has - * at most one owner, a write by anyone else is refused, and a ROM switch is one - * locked step: the outgoing controller's final flush, then the incoming claim. - * A refused write leaves the controller's dirty flag set (the caller clears it - * only inside [write]), so nothing is lost by refusing a write that was in - * fact the incoming owner's, made before its claim landed: the next flush - * retries it. + * So ownership moves in [transfer], which does no I/O and is called on the + * main thread in the same step that swaps the running controller: there is no + * window in which the running controller does not own its key. The outgoing + * controller keeps a RETIRING right to its old key, so its final flush can run + * later on an I/O thread, and loses it the moment the new owner first writes + * that key: an older image can never replace a newer one. [finish] drops the + * retiring right once the final flush has run, so the map does not keep a + * finished controller (and its native memory) alive. * - * Identity, not equality: owners are compared with `===`, because two + * Identity, not equality: controllers are compared with `===`, because two * controllers for the same disk are equal in every way that matters except * which one the game loop is running. */ internal class SaveOwners { private val owners = HashMap() + private val retiring = HashMap() - /** Run [block] only if [ctrl] owns [key]; true when it ran. */ + /** + * Run [block] if [ctrl] owns [key], or is retiring from it and its owner + * has not written it since; true when it ran. A write by the owner ends + * any retiring right to the key. + */ @Synchronized fun write(key: String, ctrl: C, block: () -> Unit): Boolean { - if (owners[key] !== ctrl) return false - block() + when { + owners[key] === ctrl -> { + block() + retiring.remove(key) + } + retiring[key] === ctrl -> block() + else -> return false + } return true } /** - * Retire [outCtrl] from [outKey] (running [finalFlush] first if it still - * owns it) and give [inKey] to [inCtrl], with no write from either side - * able to land in between. Either side may be absent: the first load has - * nothing outgoing. + * Move [outCtrl] off [outKey] (to a retiring right, if it owned it) and + * give [inKey] to [inCtrl]. No I/O: call it where the running controller + * is swapped. Either side may be absent; the first load has nothing + * outgoing, and a close has nothing incoming. */ @Synchronized - fun handOver(outKey: String?, outCtrl: C?, inKey: String?, inCtrl: C?, finalFlush: () -> Unit) { + fun transfer(outKey: String?, outCtrl: C?, inKey: String?, inCtrl: C?) { if (outKey != null && outCtrl != null && owners[outKey] === outCtrl) { - finalFlush() owners.remove(outKey) + retiring[outKey] = outCtrl } if (inKey != null && inCtrl != null) owners[inKey] = inCtrl } - /** End [ctrl]'s ownership of [key]; a release by a former owner is ignored. */ + /** The final flush of a retired [ctrl] has run: drop its right to [key]. */ + @Synchronized + fun finish(key: String, ctrl: C) { + if (retiring[key] === ctrl) retiring.remove(key) + } + + /** + * Closing [ctrl]'s session: give up [key], run [finalFlush] if it owned it, + * and keep nothing. One locked step. A close by a former owner is ignored. + */ @Synchronized - fun release(key: String, ctrl: C) { - if (owners[key] === ctrl) owners.remove(key) + fun close(key: String, ctrl: C, finalFlush: () -> Unit) { + if (owners[key] !== ctrl) return + owners.remove(key) + finalFlush() } } diff --git a/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt b/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt index 09f382b6..1be6b9e5 100644 --- a/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt +++ b/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt @@ -7,78 +7,116 @@ import org.junit.Test /** * v3.2.0: the fence that decides which controller may write a SHA-keyed FDS - * save. Review on #604 found two ways an OUTGOING controller's disk image - * reached a file it no longer owned: the game loop paired the controller it - * captured with the ROM identity it read later, across a ROM switch (Cursor), - * and a flush queued before the switch ran after the new controller had - * written the same file, on reopening the same disk (CodeRabbit). Both are - * writes from a controller that is not the file's current owner. + * save. Review on #604 found three ways a write went to the wrong place or + * was lost: + * + * - the game loop paired the controller it captured with the ROM identity it + * read later, across a ROM switch, sending the old disk to the new game's + * file (Cursor); + * - a flush queued by the outgoing controller ran after the incoming one had + * written the same file, on reopening the same disk (CodeRabbit); + * - the first fix claimed ownership later, on an I/O thread, so a close or a + * pause before the claim was refused and never retried, and two quick + * switches could install a retired controller as owner (Cursor, on the fix). + * + * Ownership therefore moves synchronously ([SaveOwners.transfer]), and the + * outgoing controller keeps a RETIRING right to its key until the new owner + * first writes it. */ class SaveOwnersTest { private val a = "a".repeat(64) private val b = "b".repeat(64) - /** The first load claims its key; a controller that never claimed is refused. */ + /** A controller that was never given a key cannot write it. */ @Test - fun only_the_claimant_may_write() { + fun only_the_owner_may_write() { val owners = SaveOwners() val ctrl = Any() assertFalse(owners.write(a, ctrl) { error("must not run") }) - owners.handOver(null, null, a, ctrl) { error("nothing outgoing") } + owners.transfer(null, null, a, ctrl) assertTrue(owners.write(a, ctrl) {}) } - /** Cursor's case: the old controller paired with the NEW game's key. */ + /** Cursor's first case: the old controller paired with the NEW game's key. */ @Test fun an_outgoing_controller_cannot_write_the_incoming_key() { val owners = SaveOwners() val old = Any() val new = Any() - owners.handOver(null, null, a, old) {} - owners.handOver(a, old, b, new) {} + owners.transfer(null, null, a, old) + owners.transfer(a, old, b, new) assertFalse(owners.write(b, old) { error("old image into the new game's file") }) - assertFalse(owners.write(a, old) { error("old no longer owns its key either") }) assertTrue(owners.write(b, new) {}) } + /** The outgoing controller's final flush lands on its own key after the switch. */ + @Test + fun the_outgoing_final_flush_is_allowed_on_its_own_key() { + val owners = SaveOwners() + val old = Any() + val new = Any() + owners.transfer(null, null, a, old) + owners.transfer(a, old, b, new) + assertTrue(owners.write(a, old) {}) + owners.finish(a, old) + assertFalse(owners.write(a, old) { error("finished: nothing may write for it") }) + } + /** - * CodeRabbit's case: the same disk reopened. The outgoing final flush runs - * inside the handover, before the claim, and a flush the old controller - * queued earlier is refused once the new one owns the key, so it can never - * land after the new controller's write. + * CodeRabbit's case: the same disk reopened. The old controller may write + * until the new one does, and never after, so an older image can never + * replace a newer one. */ @Test - fun reopening_the_same_key_orders_the_final_flush_first() { + fun reopening_the_same_key_never_writes_old_over_new() { val owners = SaveOwners() val old = Any() val new = Any() val log = mutableListOf() - owners.handOver(null, null, a, old) {} - owners.handOver(a, old, a, new) { log += "old final" } + owners.transfer(null, null, a, old) + owners.transfer(a, old, a, new) + assertTrue(owners.write(a, old) { log += "old final" }) assertTrue(owners.write(a, new) { log += "new" }) assertFalse(owners.write(a, old) { log += "old stale" }) assertEquals(listOf("old final", "new"), log) } - /** Closing a ROM releases the key, so nothing writes it until the next claim. */ + /** Cursor's case on the first fix: a close straight after a load still writes. */ @Test - fun release_ends_ownership() { + fun a_close_straight_after_a_load_writes() { val owners = SaveOwners() val ctrl = Any() - owners.handOver(null, null, a, ctrl) {} - owners.release(a, ctrl) - assertFalse(owners.write(a, ctrl) { error("released") }) + owners.transfer(null, null, a, ctrl) + var wrote = false + owners.close(a, ctrl) { wrote = true } + assertTrue(wrote) + assertFalse(owners.write(a, ctrl) { error("closed") }) + } + + /** Cursor's other case: two quick switches leave the LIVE controller owning the key. */ + @Test + fun two_quick_switches_leave_the_live_controller_owning() { + val owners = SaveOwners() + val first = Any() + val second = Any() + val live = Any() + owners.transfer(null, null, a, first) + owners.transfer(a, first, a, second) + owners.transfer(a, second, a, live) + assertTrue(owners.write(a, live) {}) + assertFalse(owners.write(a, first) { error("long retired") }) + assertFalse(owners.write(a, second) { error("the live owner has written") }) } - /** A release by a controller that no longer owns the key leaves the owner alone. */ + /** A close by a controller that no longer owns the key changes nothing. */ @Test - fun a_stale_release_is_ignored() { + fun a_stale_close_is_ignored() { val owners = SaveOwners() val old = Any() val new = Any() - owners.handOver(null, null, a, old) {} - owners.handOver(a, old, a, new) {} - owners.release(a, old) + owners.transfer(null, null, a, old) + owners.transfer(a, old, a, new) + owners.close(a, old) {} assertTrue(owners.write(a, new) {}) } } diff --git a/docs/android.md b/docs/android.md index 6c94a5f2..933c4bf9 100644 --- a/docs/android.md +++ b/docs/android.md @@ -293,11 +293,13 @@ second) and every session-ending path (background, ROM switch, close) write `filesDir/fds-saves/.fds.sav`, the desktop's layout. The flag is cleared BEFORE the image is copied, so a disk write landing during the save sets it again and the next flush catches it; a failed file write is retried by the -next once-a-second check. Each key has one owner (`SaveOwners`): only the -controller the loop is running may write its game's file, and a ROM switch is -one locked step, the outgoing disk's last write and then the incoming claim, so -no flush can put one game's disk in another's file or an older image over a -newer one when the same disk is reopened (`SaveOwnersTest`). The next load builds from the pristine image and +next once-a-second check. Each key has one owner (`SaveOwners`): the key moves +to the incoming controller on the main thread, with no I/O, in the same step +that swaps the running controller, so there is no moment in which the running +disk cannot be saved. The outgoing controller keeps the right to write its own +key for its final flush only until the new owner first writes it, so no flush +can put one game's disk in another's file, or an older image over a newer one +when the same disk is reopened (`SaveOwnersTest`). The next load builds from the pristine image and calls `bootSavedDisk`, which boots the saved copy under the PRISTINE disk's identity, so the save, the states, RA progress and overrides keep one key (loading the saved file as the ROM would change it at the first disk write). A bad or From e502339fc26633d12526d2f8e04415ab09618219 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 19:28:52 -0400 Subject: [PATCH 093/104] fix(scripts): quiet_ab queue writes its marker before dequeuing 4f9cac8e added an in-progress marker so an interrupted A/B is re-queued deliberately instead of lost, but wrote it AFTER removing the entry from the queue file. An interruption between the two steps still lost the entry, and the marker was removed even when appending the result to FILE.done failed (CodeRabbit on #605, follow-up to the original thread). Order now: write and check FILE.inprogress; remove the entry; run; append the result; remove the marker only if the append succeeded. Interrupted between marker and removal, the entry is in both places and the existing startup refusal names it. A failed removal removes the marker, keeps the entry queued and exits 2. A failed append keeps the marker and exits 2. The queue rewrite also went through `open(path, "w")`, which truncates in place, so dying mid-write lost every queued entry; it now writes FILE.tmp and renames it over the queue (os.replace). Verified in a throwaway repository with a stub ab_check.sh (QUIET_LOAD=9999): normal, two entries -> rc 0, both in .done, queue empty, no marker removal fails (.tmp dir) -> rc 2, entry still queued, no marker append fails (.done dir) -> rc 2, marker kept naming the entry run killed mid-benchmark -> entry gone from queue, marker names it; rerun refuses with rc 2 and names it shellcheck clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/perf/quiet_ab.sh | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh index 33a2cd83..c98f044b 100755 --- a/scripts/perf/quiet_ab.sh +++ b/scripts/perf/quiet_ab.sh @@ -103,22 +103,43 @@ case "${1:-}" in while :; do line=$(grep -v '^[[:space:]]*$' "$q" | grep -v '^#' | head -1) if [ -z "$line" ] || [[ "$line" == STOP* ]]; then echo "queue empty"; break; fi + # An interrupted run is a void measurement: the marker says which one, + # so it is re-queued deliberately rather than silently lost. It is + # written, and checked, BEFORE the entry leaves the queue: the other + # order lost the entry to an interruption between the two steps + # (CodeRabbit on #605). Interrupted after the marker and before the + # removal, the entry is in both places, and the refusal above names it. + echo "$line" > "$q.inprogress" || { echo "quiet_ab.sh: cannot write $q.inprogress" >&2; exit 2; } # Remove exactly that first occurrence, so lines appended meanwhile stay. - python3 - "$q" "$line" <<'PY' -import sys + # Written to a temporary file and renamed over the queue, because + # truncating the queue in place and then dying loses every entry. + if ! python3 - "$q" "$line" <<'PY' +import os, sys path, line = sys.argv[1], sys.argv[2] lines = open(path).read().splitlines() lines.remove(line) -open(path, "w").write("\n".join(lines) + ("\n" if lines else "")) +tmp = path + ".tmp" +with open(tmp, "w") as f: + f.write("\n".join(lines) + ("\n" if lines else "")) +os.replace(tmp, path) PY - # An interrupted run is a void measurement: the marker says which one, - # so it is re-queued deliberately rather than silently lost. - echo "$line" > "$q.inprogress" + then + echo "quiet_ab.sh: could not remove '$line' from $q; it stays queued" >&2 + rm -f "$q.inprogress" + exit 2 + fi # `line` holds LABEL BASE and flags, which must word-split. # shellcheck disable=SC2086 run_one $line - echo "$line rc=$?" >> "$q.done" - rm -f "$q.inprogress" + rc=$? + # The marker goes only once the result is recorded: if the append + # fails, the marker is the one remaining record of the run. + if echo "$line rc=$rc" >> "$q.done"; then + rm -f "$q.inprogress" + else + echo "quiet_ab.sh: could not record '$line rc=$rc' in $q.done; $q.inprogress kept" >&2 + exit 2 + fi done ;; heavy) shift From 6e1ad93a8abfc41edd1ecc4641ba2cd3fa4276ce Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 19:50:37 -0400 Subject: [PATCH 094/104] fix(scripts): make the quiet_ab queue append-only, locked, and honest CodeRabbit's re-check of e502339f on #604 found three problems the marker-first ordering did not touch, all in how the queue consumed its file, plus one left over from the original Codex finding: 1. Two queue processes could pass the .inprogress check and take the same entry. The benchmark flock orders measurements against heavy jobs; it never covered choosing an entry or recording a result. 2. The rewrite opened the fixed name FILE.tmp with "w", so a planted symlink there redirected the write. 3. A line appended between reading the queue and os.replace was lost. Any rewrite of a file that others append to has this window. 4. A failed benchmark was recorded as rc=N in FILE.done, but the queue still drained and exited 0. The representation was the defect, so it changes. FILE is now APPEND-ONLY: the script never writes it. Progress is a count of entries taken, kept in FILE.pos and advanced through a mktemp file (O_EXCL, a unique name, so neither an existing file nor a symlink) renamed over it. An append can therefore only add entries (3), there is no fixed temporary name (2), and FILE.lock (flock -n, held for the whole run) admits one processor per queue (1). The marker is still written before the count moves and removed only after the result is recorded. The queue exits 1 if any entry failed (4). Deleting FILE.pos re-runs a queue from the top; STOP still holds it. The header documents all of it. Verified in a throwaway repository with a stub ab_check.sh (QUIET_LOAD=9999): comments/blank lines, two entries -> rc 0, pos 2, both in .done, no marker, FILE unchanged entry appended later -> resumes at the cursor (pos 3) a failing entry, then a good one -> both run, rc 1, rc=3 recorded entry appended DURING a run -> kept and run (pos 7) second processor while running -> refused, rc 2; entry ran once symlinks planted at q.tmp and q.pos.tmp -> untouched, no stray temp files STOP line -> "queue empty", cursor unchanged shellcheck clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/perf/quiet_ab.sh | 65 +++++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh index c98f044b..e05a7377 100755 --- a/scripts/perf/quiet_ab.sh +++ b/scripts/perf/quiet_ab.sh @@ -19,10 +19,14 @@ # headed by the load at the start and the end, goes to LOG_DIR/LABEL.log. # # quiet_ab.sh queue FILE -# Pop lines `LABEL BASE [args...]` from FILE one at a time and `run` each, -# until FILE is empty or its first line is STOP. Lines may be appended -# while it runs; `#` lines and blank lines are skipped. Each finished line -# is appended to FILE.done. +# `run` the lines `LABEL BASE [args...]` of FILE in order, until the next +# one is missing or starts with STOP; `#` lines and blank lines are +# skipped. FILE is APPEND-ONLY: it is never rewritten, so lines may be +# appended while it runs (`echo ... >> FILE`) and none is ever lost. +# Progress is a count of entries taken, in FILE.pos; each finished entry +# is appended to FILE.done with its exit status, and the queue exits 1 if +# any entry failed. One process per queue (FILE.lock); a second refuses. +# To re-run a queue from the top, delete FILE.pos. # # quiet_ab.sh heavy CMD [args...] # Wait until no measurement holds MEASURING, then hold BUSY while CMD runs, @@ -94,44 +98,46 @@ case "${1:-}" in queue) q="${2:-}" [ -f "$q" ] || { echo "usage: quiet_ab.sh queue FILE (FILE must exist)" >&2; exit 2; } + # ONE processor per queue, for the whole run. The benchmark lock orders + # measurements against heavy jobs; it never protected choosing an entry + # or recording a result, so two queue processes could take the same one + # (CodeRabbit on #604). + exec 8>> "$q.lock" + flock -n 8 || { echo "quiet_ab.sh: $q is already being processed (holder of $q.lock)" >&2; exit 2; } if [ -e "$q.inprogress" ]; then echo "quiet_ab.sh: $q.inprogress names a run that was interrupted: $(cat "$q.inprogress")" >&2 echo "quiet_ab.sh: re-queue it if it is still wanted, then delete the marker" >&2 exit 2 fi trap 'rm -f "$measuring"' EXIT + failed=0 while :; do - line=$(grep -v '^[[:space:]]*$' "$q" | grep -v '^#' | head -1) + # FILE is never rewritten. The first version removed each entry by + # rewriting the file, which lost a line appended between the read and + # the write, and its fixed `FILE.tmp` name could be a planted symlink + # (CodeRabbit on #604). Progress is a count instead, and an append can + # only ever add entries. + pos=$(cat "$q.pos" 2>/dev/null || echo 0) + case "$pos" in ''|*[!0-9]*) echo "quiet_ab.sh: $q.pos is not a count: '$pos'" >&2; exit 2 ;; esac + line=$(grep -v '^[[:space:]]*$' "$q" | grep -v '^[[:space:]]*#' | sed -n "$((pos + 1))p") if [ -z "$line" ] || [[ "$line" == STOP* ]]; then echo "queue empty"; break; fi - # An interrupted run is a void measurement: the marker says which one, - # so it is re-queued deliberately rather than silently lost. It is - # written, and checked, BEFORE the entry leaves the queue: the other - # order lost the entry to an interruption between the two steps - # (CodeRabbit on #605). Interrupted after the marker and before the - # removal, the entry is in both places, and the refusal above names it. + # The marker is written, and checked, BEFORE the count moves: interrupted + # in between, the entry is still pending AND named by the marker, and the + # refusal above stops a silent re-run (CodeRabbit on #605). echo "$line" > "$q.inprogress" || { echo "quiet_ab.sh: cannot write $q.inprogress" >&2; exit 2; } - # Remove exactly that first occurrence, so lines appended meanwhile stay. - # Written to a temporary file and renamed over the queue, because - # truncating the queue in place and then dying loses every entry. - if ! python3 - "$q" "$line" <<'PY' -import os, sys -path, line = sys.argv[1], sys.argv[2] -lines = open(path).read().splitlines() -lines.remove(line) -tmp = path + ".tmp" -with open(tmp, "w") as f: - f.write("\n".join(lines) + ("\n" if lines else "")) -os.replace(tmp, path) -PY - then - echo "quiet_ab.sh: could not remove '$line' from $q; it stays queued" >&2 - rm -f "$q.inprogress" + # A unique file made by mktemp (O_EXCL, so never an existing name or a + # symlink), renamed over the count. + tmp="" + if ! { tmp=$(mktemp "$q.pos.XXXXXX") && echo "$((pos + 1))" > "$tmp" && mv -f "$tmp" "$q.pos"; }; then + echo "quiet_ab.sh: could not advance $q.pos; '$line' stays pending" >&2 + rm -f "$tmp" "$q.inprogress" exit 2 fi # `line` holds LABEL BASE and flags, which must word-split. # shellcheck disable=SC2086 run_one $line rc=$? + [ "$rc" -eq 0 ] || failed=1 # The marker goes only once the result is recorded: if the append # fails, the marker is the one remaining record of the run. if echo "$line rc=$rc" >> "$q.done"; then @@ -140,7 +146,10 @@ PY echo "quiet_ab.sh: could not record '$line rc=$rc' in $q.done; $q.inprogress kept" >&2 exit 2 fi - done ;; + done + # A queue that drained after a failed benchmark used to exit 0 + # (Codex, then CodeRabbit on #604); FILE.done says which one failed. + [ "$failed" -eq 0 ] || { echo "quiet_ab.sh: at least one queued run failed; see $q.done" >&2; exit 1; } ;; heavy) shift [ $# -ge 1 ] || { echo "usage: quiet_ab.sh heavy CMD [args...]" >&2; exit 2; } From f4476698927600332c4e1bdd8e086a4e316c4ae4 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 20:42:44 -0400 Subject: [PATCH 095/104] fix(scripts): quiet_ab's marker says whether the queue moved past it After 6e1ad93a an interruption could leave .inprogress in two states with identical content: before the FILE.pos rename (the entry still pending) or after it (the entry taken and unfinished). The refusal told the operator to re-queue in both, and re-queuing in the pending state ran the entry twice (CodeRabbit on #604). The marker is now two lines: the count from before the entry was taken, then the entry. The refusal compares that count with FILE.pos and prints the step that fits: equal -> STILL PENDING: delete the marker and rerun, do not re-queue marker + 1 -> TAKEN: re-queue if still wanted, then delete it anything else -> inspect both by hand no count -> an old-format marker; compare by hand Verified in a throwaway repository with a stub ab_check.sh: each of the four states prints its message and exits 2; following the PENDING advice runs the entry exactly once (c-runs=1, pos 2 -> 3); a normal run still leaves no marker. shellcheck clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/perf/quiet_ab.sh | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh index e05a7377..710fd13f 100755 --- a/scripts/perf/quiet_ab.sh +++ b/scripts/perf/quiet_ab.sh @@ -105,8 +105,24 @@ case "${1:-}" in exec 8>> "$q.lock" flock -n 8 || { echo "quiet_ab.sh: $q is already being processed (holder of $q.lock)" >&2; exit 2; } if [ -e "$q.inprogress" ]; then - echo "quiet_ab.sh: $q.inprogress names a run that was interrupted: $(cat "$q.inprogress")" >&2 - echo "quiet_ab.sh: re-queue it if it is still wanted, then delete the marker" >&2 + # The marker's first line is the count BEFORE the interrupted entry was + # taken. Comparing it with FILE.pos says which of the two states the + # interruption left, so the recovery step printed is the right one: the + # same marker text used to mean both, and re-queuing in the "still + # pending" state ran the entry twice (CodeRabbit on #604). + mpos=$(sed -n 1p "$q.inprogress"); entry=$(sed -n 2p "$q.inprogress") + cur=$(cat "$q.pos" 2>/dev/null || echo 0) + case "$mpos" in ''|*[!0-9]*) mpos="?"; entry=$(cat "$q.inprogress") ;; esac + echo "quiet_ab.sh: $q.inprogress names a run that was interrupted: $entry" >&2 + if [ "$mpos" = "?" ]; then + echo "quiet_ab.sh: the marker has no count (written before v3.2.0's format); compare $q.pos ($cur) with the queue by hand" >&2 + elif [ "$cur" = "$mpos" ]; then + echo "quiet_ab.sh: it is STILL PENDING ($q.pos is $cur, not advanced): delete the marker and rerun; do not re-queue it" >&2 + elif [ "$cur" = "$((mpos + 1))" ]; then + echo "quiet_ab.sh: it was TAKEN ($q.pos moved $mpos -> $cur) and did not finish: re-queue it if it is still wanted, then delete the marker" >&2 + else + echo "quiet_ab.sh: $q.pos is $cur and the marker recorded $mpos; inspect both by hand before deleting the marker" >&2 + fi exit 2 fi trap 'rm -f "$measuring"' EXIT @@ -124,7 +140,7 @@ case "${1:-}" in # The marker is written, and checked, BEFORE the count moves: interrupted # in between, the entry is still pending AND named by the marker, and the # refusal above stops a silent re-run (CodeRabbit on #605). - echo "$line" > "$q.inprogress" || { echo "quiet_ab.sh: cannot write $q.inprogress" >&2; exit 2; } + printf '%s\n%s\n' "$pos" "$line" > "$q.inprogress" || { echo "quiet_ab.sh: cannot write $q.inprogress" >&2; exit 2; } # A unique file made by mktemp (O_EXCL, so never an existing name or a # symlink), renamed over the count. tmp="" From 4f011f14dce39c00481c354e8464436e2dc7920f Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 20:51:07 -0400 Subject: [PATCH 096/104] docs: close the v3.2.0 sprint table; record two review-ceremony traps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's sprint table still read "—" for S1, S3, S4 and S5. Each now states its outcome, taken from the CHANGELOG, external_coverage.rs and the MiSTer repository's rung-7 record rather than from memory: - S1: the 17 families (191 -> 208) by number, all Curated but 230; both Doraemon World 3 hacks and the mapper-8 dump boot. - S3: custom palette and the >8 sprites option, gated, not seen on hardware. - S4: the sixteen boards; MMC2/MMC4 (9/10) moved to v3.4.0 as the line plan has them, because their CHR latch flips on a PPU fetch and the extra-sprite read path must not (rung7-mappers.md). - S5: the input devices; the sweep and ladders are recorded in the CHANGELOG and the MiSTer README. T-DUMP-CORPUS is marked DONE: its gap was 154, 243, 268 and 290, staged and blessed in S2. Two traps from this release's review round go into docs/agents/: - tooling-traps.md: `gh api --paginate --slurp --jq` is an error, and with stderr discarded the audit printed an empty "no reply" column, which read as an all-clear and was reported as one. - review-bots.md: thread replies CodeRabbit answered only with "Rate Limit Exceeded" (27 on #604, 6 on #59) were never evaluated; one consolidated re-check after the window found four real open findings. AGENTS.md's index counts are corrected to the files: review-bots 14, tooling-traps 25, and two that had drifted before this change (libretro 17 -> 19, dependencies 3 -> 5). Every count now matches `grep -c '^- '`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- AGENTS.md | 8 ++++---- docs/agents/review-bots.md | 1 + docs/agents/tooling-traps.md | 1 + to-dos/ROADMAP.md | 2 +- to-dos/plans/v3.2.0-plan.md | 8 ++++---- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cd367658..11fcbab4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,13 +214,13 @@ what a session needs before it knows what it is doing. | read when the task touches | file | bullets | |---|---|---| | GitHub Actions, workflows, the release ceremony, lint coverage | [`docs/agents/ci-and-release.md`](docs/agents/ci-and-release.md) | 25 | -| a PR review — the ceremony, where findings hide, which claims recur | [`docs/agents/review-bots.md`](docs/agents/review-bots.md) | 13 | -| the libretro core, the buildbot, the upstream `.info`, RetroArch | [`docs/agents/libretro.md`](docs/agents/libretro.md) | 17 | +| a PR review — the ceremony, where findings hide, which claims recur | [`docs/agents/review-bots.md`](docs/agents/review-bots.md) | 14 | +| the libretro core, the buildbot, the upstream `.info`, RetroArch | [`docs/agents/libretro.md`](docs/agents/libretro.md) | 19 | | the MiSTer sibling — the RTL, Quartus, the rungs, the bitstream | [`docs/agents/mister-cosim.md`](docs/agents/mister-cosim.md) | 35 | | accuracy work — AccuracyCoin, blargg, sub-tests, goldens, the PPU | [`docs/agents/accuracy-oracle.md`](docs/agents/accuracy-oracle.md) | 23 | | reading a result — what it does and does not prove | [`docs/agents/measurement-discipline.md`](docs/agents/measurement-discipline.md) | 22 | -| the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 24 | -| a dependency bump, or why one is blocked | [`docs/agents/dependencies.md`](docs/agents/dependencies.md) | 3 | +| the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 25 | +| a dependency bump, or why one is blocked | [`docs/agents/dependencies.md`](docs/agents/dependencies.md) | 5 | | a performance claim, or a debugger panel that outlives its `Nes` | [`docs/agents/perf-and-panels.md`](docs/agents/perf-and-panels.md) | 8 | **Read the file, not a summary of it.** Each bullet carries its own evidence — diff --git a/docs/agents/review-bots.md b/docs/agents/review-bots.md index 54433c34..d895f4e9 100644 --- a/docs/agents/review-bots.md +++ b/docs/agents/review-bots.md @@ -34,3 +34,4 @@ - **COPILOT REVIEWS ONCE PER PR HERE, AND DOCS7 FLAGS THE PRIVATE SIBLING AS DEAD (2026-09-23).** On all three PRs Copilot posted a single review, on the first push, and never re-reviewed later pushes. So the merge gate can only check that it *has* posted, not that it reviewed the head; agy's comment timestamp is the per-push signal. Context7's **Docs7** bot (a `context7[bot]` issue comment plus a NEUTRAL check) link-checks changed files as an **anonymous** client. Every `https://github.com/doublegate/RustyNES_MiSTer/...` link is therefore reported dead, because that repository is **private** (`gh repo view --json visibility` returns `PRIVATE`). Those are correct links, not findings. Its other catch was real: two pre-existing links into `to-dos/` directories that had moved to `to-dos/archive/`. - **CODERABBIT SKIPS A PR OVER 100 FILES; REVIEW IT AS SLICES, AND EXPECT CROSS-SLICE FALSE FINDINGS (v2.9.8, v3.0.0).** "Review skipped: 163 files exceed the limit of 100." Both MAJOR release PRs were reviewed as review-only slice PRs (`review/vX-{a,b}` from `main`, one path partition each, built with `git diff --binary -- | git apply --index`, and proved equal to the head on their paths with `git diff --quiet -- `). A slice does not build alone, so it is committed with `--no-verify` and says so. Keep the slices in sync on every release-branch push, and close them unmerged. The trap: a slice lacks the other half's changes. On #587 CodeRabbit rated Major that `rustynes-core` still built the now-`#[non_exhaustive]` `Cartridge` by literal; that change was in the other slice. Shown the release head's lines, it withdrew. It is also rate-limited hourly on this plan, so schedule the re-request rather than re-posting. **A slice is also RED in CI by construction (v3.0.1, #591)**: feature-gated tests that live in one slice and depend on the other (`holy_mapperel.rs`, the provenance audit reading files the other slice changes) fail there and nowhere else. Say so on the slice PR at once, with the named tests, and judge CI on the release PR only. **Split CODE from DOCS and the slices pass the hooks (v3.1.0, #595 / #596).** Partitioned by extension (`.rs .toml .lock .wgsl .kts .yml .py` in one slice, everything else in the other), the code slice builds exactly as the release head does and the docs slice touches no Rust, so both were committed with the pre-commit hooks running and no `--no-verify`. Two things to keep: re-derive the partition from `git diff --name-only HEAD` on EVERY sync, because a newly touched path (v3.1.0's `release_notes_render_audit.rs`) is otherwise silently left out of both slices; and prove each slice equal to the head with `git diff --quiet -- ` after applying. CodeRabbit found real defects in both halves (two behaviour bugs in the code slice, a `LADDER-FILL` placeholder in the CHANGELOG in the docs slice). - **AGY'S BLOCKING FINDINGS CAN CITE CODE THAT DOES NOT EXIST (v3.0.0, #586).** One round filed a BLOCKING "state desync in `SpectatorSession::pop_frame`... `push_frame` relies on `relative_idx`"; none of the three exists in the spectator (`git grep` found only the movie attestation builder's `push_frame`). The same round cited `crates/rustynes-core/src/netplay.rs` and `scripts/bump_release.py`, neither of which exists. Another round called `let _ = socket.send_to(..)` a silent failure, against the module's documented contract and four sibling sends. agy edits ONE comment in place, so these sat in the folded "Earlier review rounds" block. `git grep` every cited symbol and `ls` every cited path before acting; refute with the command and the line numbers. +- **A THREAD REPLY CODERABBIT ANSWERS WITH "Rate Limit Exceeded" WAS NEVER EVALUATED (v3.2.0, #604 / #59).** Replying to 30 threads in a burst exceeds its hourly chat limit, and every reply past the limit gets "`@doublegate` have exceeded the limit for the number of chat messages per hour. Please wait N minutes" — 27 on #604 and 6 on #59. Those threads were resolved, had a reply, and read as closed. They were not reviewed: one consolidated request after the window ("re-read every thread against the current head, list any incomplete fix in ONE comment, do not reply in the threads") came back with four real open findings (a queue that two processes could share, a predictable temp-file name, a lost append, a mutation tool that called an empty golden CAUGHT). **After a reply burst, grep the follow-ups for "Rate Limit Exceeded"; if any, wait out the window and ask for the one consolidated re-check.** Its "Review thread resolved" and "I withdraw this finding" follow-ups are the evaluated ones. diff --git a/docs/agents/tooling-traps.md b/docs/agents/tooling-traps.md index 0c067888..98389020 100644 --- a/docs/agents/tooling-traps.md +++ b/docs/agents/tooling-traps.md @@ -36,3 +36,4 @@ - **`git show : > FILE` ON AN UNFETCHED TAG WRITES AN EMPTY FILE (v3.1.0).** The shell creates `FILE` before `git show` runs, so when the tag is not in the local repository (a release tag made on GitHub by Auto Release is not fetched by `git fetch origin main`), the command prints `fatal: invalid object name` and leaves a zero-byte file behind. Copied into the libretro-super fork, that was committed and pushed as a 38-line deletion of `rustynes_libretro.info` before the diff was read (caught before the PR, repaired by amend). Fetch the tag first (`git fetch origin tag vX.Y.Z`), and check `[ -s FILE ]` plus the `git diff --stat` before any commit built from a redirect. - **`workflow_dispatch` RUNS ONLY A WORKFLOW THAT IS ON THE DEFAULT BRANCH (v3.1.0).** `gh workflow run ladder.yml --ref release/v3.1.0` returned HTTP 404 "workflow ladder.yml not found on the default branch" while the file existed on the release branch. A new dispatch-only workflow therefore cannot be exercised before its PR merges; its first run is after the merge, and the PR should say so rather than imply it was tested. - **`git -C ` RESOLVES A RELATIVE PATH ARGUMENT INSIDE `` (v3.2.0).** `git -C RustyNES_MiSTer worktree add RustyNES_MiSTer-wt-ladder-on `, run from the parent directory, created the worktree INSIDE the sibling (`RustyNES_MiSTer/RustyNES_MiSTer-wt-ladder-on`, an untracked directory in its tree), so the background ladders that `cd`'d to the intended sibling-of-the-repo path exited at once and read as "completed". The same session, `git -C hash-object ` hashed nothing and a 175-file salvage comparison reported every file as uncommitted. **Pass absolute paths to anything run under `git -C`, and treat an instant "completed" from a long job as a failure to start.** +- **`gh api --paginate --slurp` WITH `--jq` IS AN ERROR, AND `2>/dev/null` TURNS IT INTO AN ALL-CLEAR (v3.2.0).** `gh` refuses the combination ("the `--slurp` option is not supported with `--jq` or `--template`") and prints its usage to stderr. A review audit that computed "inline roots without my reply" that way, with stderr discarded, printed an empty column for all six PRs, and an empty column reads exactly like "every thread answered". It was reported to the maintainer as a clean result. The truth, measured once the command was fixed: every root did have a reply, but 33 of those replies had been answered only by CodeRabbit's rate-limit notice (review-bots.md). **Pipe `gh api --paginate --slurp` into `jq`, never pass `--jq`; and never silence stderr on a command whose empty output means success.** `set -euo pipefail` in the audit script would have failed loudly. diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index d4f5d63b..390148d8 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1458,7 +1458,7 @@ re-minted: `T-PS-dual-runahead` (ADR 0032; **DONE v3.1.0**), `T-MISTER-SAVESTATE | `T-MAPPER-BREADTH-V3` | The missing families with real titles, both cores (D26). **v3.2.0 part DONE**: 17 oracle families (191 -> 208), 16 MiSTer boards; the rest is v3.8.0's | MAP-03, MAP-05, FB-1 | v3.2.0, v3.8.0 | | `T-KNOWN-BLANK` | Triage the 52 KNOWN_BLANK dumps. **v3.2.0**: 41 remain; 10 Famicom-only carts boot on the Famicom model (`FAMICOM_WIRED`) | ACC-08, ACC-09 | v3.2.0 | | `T-CURATED-EVIDENCE` | BestEffort → Curated on local-dump evidence under D23's record. **DONE v3.2.0**: 47, 121, 154, 191 and 243 (`docs/compatibility.md`) | MAP-01 | v3.2.0 | -| `T-DUMP-CORPUS` | Every implemented family with a dump in `~/Dropbox/ROMs/` gets a recorded boot; nothing committed but snapshots (D21) | MAP-02 | v3.2.0 | +| `T-DUMP-CORPUS` | Every implemented family with a dump in `~/Dropbox/ROMs/` gets a recorded boot; nothing committed but snapshots (D21). **DONE v3.2.0**: the gap was the families with a library dump and nothing staged (154, 243, 268, 290; `external_coverage.rs` snapshots every staged dump), now staged and blessed | MAP-02 | v3.2.0 | | `T-PHI2-WRITE` | Reopen PPU register write placement against the v2.6.17 conditions (D20) | ACC-03 | v3.3.0 | | `T-SPRITE0-STALE` | The sprite-0 stale shifter and the internal/external bus split (D20) | ACC-04 | v3.3.0 | | `T-HOSTED-NETPLAY` | A Cloudflare Worker lobby (Durable Objects) and Cloudflare TURN (D19) | NET-04, NET-02, NET-03 | v3.4.0 | diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index 5669e673..260f12e0 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -107,11 +107,11 @@ until the hardware release shipped.) The codename is asked at the cut. | Sprint | Deliverable | Gate | Status | | --- | --- | --- | --- | -| 3.2-S1 | Items 1 and 4 (oracle families and the corpus) | rows 1, 4 | — | +| 3.2-S1 | Items 1 and 4 (oracle families and the corpus) | rows 1, 4 | **Done.** 17 families, 191 -> 208: 157 (Datach, with the barcode reader), 6, 8 and 17 (FFE), 158, 172, 173, 207, 230, 235, 252, and 114, 182, 215, 197, 165 and 116; every one written from its NESdev page, all Curated but 230 (BestEffort, ADR 0011 amendment). Both *Doraemon World 3* hacks and the library's mapper-8 dump boot (`external_coverage.rs`). Item 4's gap (154, 243, 268, 290) was staged and blessed in S2 | | 3.2-S2 | Items 2 and 3 | rows 2, 3 | item 3 done: 47, 121, 154, 191, 243 Curated (51 / 114 / 26 at that point; 51 / 130 / 27 at the release, after the misc families and the ADR 0011 tier ruling); 290 stays BestEffort (its boot fix is recorded above; its capture's top band is unexplained); item 4's gap (154, 243, 268, 290) staged and blessed; item 2 done (KNOWN_BLANK 52 -> 41, every remaining entry triaged or previously documented) | -| 3.2-S3 | Item 5, the sibling options, first | row 5 | — | -| 3.2-S4 | Items 6 and 7, the sibling mappers | rows 6, 7 | — | -| 3.2-S5 | Item 8, then the seed sweep and the release cut | row 8; the sweep; both ladders | — | +| 3.2-S3 | Item 5, the sibling options, first | row 5 | **Done.** Custom palette (per-channel table, -1,004 ALUTs) and more than eight sprites per line, each gated by co-simulation and labelled not seen on hardware; the region placeholder stays off | +| 3.2-S4 | Items 6 and 7, the sibling mappers | rows 6, 7 | **Done.** Sixteen boards: SUROM (off-die only; SOROM/SXROM refused by NES 2.0 byte 10), 206, 66, 11, 79, 140, 118, 119, 71 (with *Fire Hawk*), 232, 34 (BNROM and NINA-001), 13, 87, 94, 180 and 185, each with bus gates, frame gates where a dump exists and a caught mutant. MMC2/MMC4 (9/10) moved to v3.4.0, as the line plan has them (the sibling's `docs/rung7-mappers.md`: their CHR latch flips on a PPU fetch, which the extra-sprite read path must not do, so they wait for that machinery) | +| 3.2-S5 | Item 8, then the seed sweep and the release cut | row 8; the sweep; both ladders | **Done.** Four Score, the Arkanoid controller and Game Genie cheats, each with an exporter flag and a bus and checkpoint gate. The seed sweep, the double compiles and both ladders are recorded in `CHANGELOG.md` and the MiSTer repository's README | ## Risks From 1cb841abc35b80698aa16e0c055d59d8a0549203 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 22:16:53 -0400 Subject: [PATCH 097/104] docs(plan): item 6 says what shipped beside what was planned The v3.2.0 plan's item 6 lists "MMC1 SUROM/SXROM", while its 3.2-S4 status records SUROM shipped and SOROM/SXROM refused (CodeRabbit on #604). The row is the scope as planned on 2026-10-08, so it is kept rather than rewritten -- a plan edited after the fact to match the outcome no longer records what was intended -- and gains the outcome: SUROM only; SXROM (Final Fantasy I+II, 32 KiB of PRG-RAM) and SOROM refused by NES 2.0 byte 10. Checked against the MiSTer header testbench's named case, which refuses "Final Fantasy I, II (SXROM, 32 KiB NVRAM)" on both builds (verdicts 3 on-die, 4 off-die). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/plans/v3.2.0-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/to-dos/plans/v3.2.0-plan.md b/to-dos/plans/v3.2.0-plan.md index 260f12e0..b3141d76 100644 --- a/to-dos/plans/v3.2.0-plan.md +++ b/to-dos/plans/v3.2.0-plan.md @@ -23,7 +23,7 @@ until the hardware release shipped.) The codename is asked at the cut. | # | Item | Gate | | --- | --- | --- | | 5 | FB-16 core options at the start of the release, before the sweep: custom palette, +8 sprites per line (mirrors FE-02), the region placeholder off | Each option checked by co-simulation where it is observable, and labelled "compiled, timing-closed, not seen on hardware" until seen | -| 6 | FB-2 MMC1 SUROM/SXROM (off-die only, 512 KiB PRG): *Dragon Warrior III/IV*, *Final Fantasy I+II* | Bus and checkpoint gates, plus a commercial-frame gate where a dump exists; a caught mutant | +| 6 | FB-2 MMC1 SUROM/SXROM (off-die only, 512 KiB PRG): *Dragon Warrior III/IV*, *Final Fantasy I+II*. As planned; **shipped: SUROM only.** SXROM (*Final Fantasy I+II*, 32 KiB of PRG-RAM) and SOROM are refused by NES 2.0 byte 10, see 3.2-S4 | Bus and checkpoint gates, plus a commercial-frame gate where a dump exists; a caught mutant | | 7 | The next ~10 by coverage per effort: the 206 family (Namco 108, a subset of MMC3 decode), 66, 11, 79, 9/10, 118/119, 71/232, 34, and the trivial discretes (13, 87, 180, 185, 94, 140) | Same as row 6, per family | | 8 | FB-10 paddle, FB-8 Four Score, FB-6 cheats (an oracle-gated rung: apply a code on both sides and compare the bus) | Each with an exporter stimulus flag and a gate | From 7646075750f72217c10e7b0fd9ff124a85c2cbae Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 22:39:22 -0400 Subject: [PATCH 098/104] fix(scripts): quiet_ab names its queue sidecars from the real path The one-processor-per-queue lock (6e1ad93a) and the cursor are files named from the path the caller typed. Through a symlink alias, `queue alias` and `queue target` therefore held two different locks and two cursors, and both processors took the same entry (CodeRabbit on #604, an outside-diff note on 1cb841ab). The queue path is now resolved with `realpath` before any sidecar is named, so an alias and its target share one .lock, .pos, .inprogress and .done. Measured in a throwaway repository with a stub ab_check.sh, one entry, a 3-second run, and a second processor started through a symlink while the first ran: previous version -> alias accepted (rc 0), the entry ran twice this change -> alias refused, "already being processed" (rc 2), the entry ran once, no alias.* sidecars created shellcheck clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/perf/quiet_ab.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh index 710fd13f..056e4560 100755 --- a/scripts/perf/quiet_ab.sh +++ b/scripts/perf/quiet_ab.sh @@ -98,6 +98,11 @@ case "${1:-}" in queue) q="${2:-}" [ -f "$q" ] || { echo "usage: quiet_ab.sh queue FILE (FILE must exist)" >&2; exit 2; } + # Every sidecar (.lock, .pos, .inprogress, .done) is named from the + # CANONICAL path: through a symlink alias the queue would otherwise get a + # second lock and a second cursor, and two processors would run it + # (CodeRabbit on #604). + q=$(realpath -- "$q") || { echo "quiet_ab.sh: cannot resolve $2" >&2; exit 2; } # ONE processor per queue, for the whole run. The benchmark lock orders # measurements against heavy jobs; it never protected choosing an entry # or recording a result, so two queue processes could take the same one From 61256216f454386e933d5e6943084fd5648517a9 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Wed, 14 Oct 2026 22:57:25 -0400 Subject: [PATCH 099/104] fix(scripts): an unresolvable A/B base fails; the queue marker is O_EXCL Two outside-diff notes from CodeRabbit's review of 76460757 on #604: 1. ab_check.sh printed "SKIP: cannot resolve base ref" and exited 0 before measuring anything. quiet_ab.sh propagates the status, so a queued A/B against a mistyped base was recorded as `rc=0`, a successful benchmark that never ran. It now prints "FAIL: ... nothing was measured" and exits 2. Nothing relied on the 0: the only caller is quiet_ab.sh, and no workflow runs ab_check.sh. The SKIP dates from v2.3.1. 2. The in-progress marker was written with a plain `>`, which follows a symlink. The startup check only sees a link whose target exists (a dangling one fails `-e`), so a link planted at the marker's name, before the run or between entries, redirected the write. It is now created under `set -C` (noclobber, O_EXCL), and refused with exit 2 if anything is already at that name. Measured in throwaway repositories with a stub benchmark: dangling symlink at q.inprogress -> previous version: followed, the victim file created, rc 0; this change: refused, rc 2, no victim `ab_check.sh --base no-such-ref` -> rc 2 with the FAIL line queue with that entry -> .done records `rc=2`, the queue exits 1 shellcheck clean on both scripts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- scripts/perf/ab_check.sh | 7 +++++-- scripts/perf/quiet_ab.sh | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/perf/ab_check.sh b/scripts/perf/ab_check.sh index 6d0faa03..df49fc0d 100755 --- a/scripts/perf/ab_check.sh +++ b/scripts/perf/ab_check.sh @@ -95,8 +95,11 @@ while [[ $# -gt 0 ]]; do done if ! base_sha="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")"; then - echo "SKIP: cannot resolve base ref '${BASE_REF}'." >&2 - exit 0 + # A failure, not a skip. Exit 0 here let a caller record a benchmark that + # never ran as a success: quiet_ab.sh's queue wrote `rc=0` to FILE.done + # (CodeRabbit on #604). No caller relies on 0; CI does not run this script. + echo "FAIL: cannot resolve base ref '${BASE_REF}'; nothing was measured." >&2 + exit 2 fi # Pin to a fixed CPU set when possible. Frequency scaling and scheduler diff --git a/scripts/perf/quiet_ab.sh b/scripts/perf/quiet_ab.sh index 056e4560..cf151905 100755 --- a/scripts/perf/quiet_ab.sh +++ b/scripts/perf/quiet_ab.sh @@ -145,7 +145,13 @@ case "${1:-}" in # The marker is written, and checked, BEFORE the count moves: interrupted # in between, the entry is still pending AND named by the marker, and the # refusal above stops a silent re-run (CodeRabbit on #605). - printf '%s\n%s\n' "$pos" "$line" > "$q.inprogress" || { echo "quiet_ab.sh: cannot write $q.inprogress" >&2; exit 2; } + # Created with noclobber (O_EXCL), so a symlink planted at the marker's + # name between entries is refused rather than followed (CodeRabbit on + # #604); the startup check sees only a link whose target exists. + if ! (set -C; printf '%s\n%s\n' "$pos" "$line" > "$q.inprogress"); then + echo "quiet_ab.sh: cannot create $q.inprogress (it exists, or is a link)" >&2 + exit 2 + fi # A unique file made by mktemp (O_EXCL, so never an existing name or a # symlink), renamed over the count. tmp="" From 87ffc23354a4608646a34d51712d4f12225d22cf Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 15 Oct 2026 02:39:20 -0400 Subject: [PATCH 100/104] docs: v3.2.0's final MiSTer figures: ladder totals, seed 4, md5s The release notes, CHANGELOG and README now carry the numbers measured on the sibling tree that ships (dea0b05; RTL as compiled at bebae8e). - Ladder: 259 / 0 / 3 on-die and 260 / 0 / 5 off-die, one frozen-worktree run each, nothing skipped (the notes said 257 / 258, before the TQROM sprite-limit gate pair was added). - Bitstreams: the release notes named the seed-2 pair from the first sweep (md5 5e9edb14... / 043fc338...), which the review fixes superseded and which was never released. They now name the seed-4 pair, each compiled twice to the same bytes: on-die e24820de2e302d3f6912524b3c97a2be, off-die c33ae2b14e5b8e07a68b4335c728ca03. The seed rule is stated as the sibling's .qsf states it: the on-die binding margin chooses, and the off-die build must close at the chosen seed (5 and 6 lead on-die but fail off-die; 1 and 4 tie next; only 4 closes off-die). - The off-die +0.038 ns is labelled as an internal path, separate from the SDRAM read capture (+0.453 / +1.180 ns, assuming zero board delay), so nobody budgets board delay against the wrong number. - CHANGELOG gains the bitstream bullet earlier releases carried. - README's MiSTer paragraph described v3.0.1 (200 / 201, "six mapper boards", seed 2); it now describes v3.2.0 (twenty-one iNES mappers, seed 4, the totals above). Verified: markdownlint (pre-commit) passes on the three files; release_notes_render_audit 16/0, release_anchor_audit 4/0, release_state_prose_audit 9/0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 8 ++++---- CHANGELOG.md | 10 ++++++++-- README.md | 13 +++++++------ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md index e3196b7b..d8eebcbf 100644 --- a/.github/release-notes/v3.2.0.md +++ b/.github/release-notes/v3.2.0.md @@ -41,10 +41,10 @@ An unpushed agent branch arrived with 61 audit findings already applied. Nothing - **The Four Score, the Arkanoid controller and Game Genie cheats**, gated against goldens exported with the same device. - **More than eight sprites per line** (on-die build) and **custom `.pal` palettes** with the emulator's emphasis model. - **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems reproduce byte for byte. -- **The co-simulation ladder**: 257 passed, 0 failed, 3 expected failures on-die and 258 / 0 / 5 off-die (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each one run of a frozen tree, nothing skipped. The new expected failures are named and graded both ways: two commercial frames still differ from the emulator's, by one pixel (a sprite at X=255 on its last row) and by three scanlines of a mid-frame raster change, neither yet attributed; off-die, two more are a CHR-RAM read-back defect that predates this release (`T-MISTER-OFFDIE-CHRRAM`). -- **New bitstreams at fitter seed 2**, chosen from eight seeds swept on build date 261014 (seed 1, v3.1.0's, no longer closes on-die). Each compiled twice to the same bytes: - - on-die `RustyNES_MiSTer-v3.2.0.rbf`, md5 `5e9edb142777cdd9346f0bd3d86f3590` (+0.091 ns setup, +0.105 ns hold); - - off-die `RustyNES_MiSTer-v3.2.0-offdie.rbf`, md5 `043fc338fb2e52da21acd8adc91d6678` (+0.040 / +0.060 ns; the SDRAM read +0.447 / +1.184 ns, assuming zero board delay), thinner than three other seeds off-die, recorded rather than bent. +- **The co-simulation ladder**: 259 passed, 0 failed, 3 expected failures on-die and 260 / 0 / 5 off-die (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each one run of a frozen tree, nothing skipped. The new expected failures are named and graded both ways: two commercial frames still differ from the emulator's, by one pixel (a sprite at X=255 on its last row) and by three scanlines of a mid-frame raster change, neither yet attributed; off-die, two more are a CHR-RAM read-back defect that predates this release (`T-MISTER-OFFDIE-CHRRAM`). +- **New bitstreams at fitter seed 4**, chosen from eight seeds swept on build date 261014 by the rule both builds share: the on-die build's binding margin chooses, and the off-die build must close at the chosen seed. Seeds 5 and 6 lead on-die but do not close off-die; seeds 1 (v3.1.0's) and 4 tie next, and only 4 closes off-die. Each was compiled twice to the same bytes: + - on-die `RustyNES_MiSTer-v3.2.0.rbf`, md5 `e24820de2e302d3f6912524b3c97a2be` (+0.213 ns setup, +0.079 ns hold); + - off-die `RustyNES_MiSTer-v3.2.0-offdie.rbf`, md5 `c33ae2b14e5b8e07a68b4335c728ca03` (+0.038 / +0.080 ns). The +0.038 ns path is inside the FPGA (the SDRAM controller's read data into the CPU), so board delay does not consume it; the SDRAM read capture itself has +0.453 ns setup and +1.180 ns hold over all four timing corners, assuming zero board delay. ## Verification diff --git a/CHANGELOG.md b/CHANGELOG.md index 43be3e48..09abdf39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,12 +172,18 @@ cycle-accurate core later replaced. registers) and is deferred, six did not hold. - **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems reproduce byte for byte from it. -- **The co-simulation ladder: 257 passed, 0 failed, 3 expected failures - on-die, 258 / 0 / 5 off-die** (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each +- **The co-simulation ladder: 259 passed, 0 failed, 3 expected failures + on-die, 260 / 0 / 5 off-die** (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each one frozen-worktree run with nothing skipped. Two commercial frames still differ from the emulator's and are expected failures, unattributed: one pixel of *Mississippi Satsujin Jiken* and three scanlines of *Micro Machines*. +- **New release-candidate bitstreams at fitter seed 4**, chosen from seeds 1-8 + at build date 261014 (seeds 1, 3, 5, 6 and 7 do not close off-die), each + compiled twice to the same bytes: on-die md5 + `e24820de2e302d3f6912524b3c97a2be` (+0.213 / +0.079 ns), off-die + `c33ae2b14e5b8e07a68b4335c728ca03` (+0.038 / +0.080 ns, an internal path; + the SDRAM read capture has +0.453 / +1.180 ns, assuming zero board delay). - **Known issue (off-die build):** a `$2007` read of CHR-RAM soon after a write returns `$00` in simulation. It predates this release, is an expected failure in the off-die ladder, and the on-die build is right. diff --git a/README.md b/README.md index 93108193..fe6d17e7 100644 --- a/README.md +++ b/README.md @@ -396,12 +396,13 @@ ported to an FPGA: what can be built is a new implementation checked against thi one, cycle by cycle. Its co-simulation ladder covers the 6502, the bus and interrupts, the 2C02, the -2A03, AccuracyCoin parity and six mapper boards. Each release attaches a timing-closed -bitstream pair; v3.0.1's (on-die and off-die, both at fitter seed 2, each compiled -twice to the same bytes) is a **release candidate, not hardware-verified**. Its -co-simulation ladder reads 200 passed, 0 failed, 1 expected failure on-die and -201 / 0 / 1 off-die. **No hardware has run any bitstream yet**, so a booting core, a synced -display, audible sound and a working pad are not claimed. The details, and what +2A03, AccuracyCoin parity and twenty-one iNES mappers. Each release attaches a +timing-closed bitstream pair; v3.2.0's (on-die and off-die, both at fitter seed 4, +each compiled twice to the same bytes) is a **release candidate, not +hardware-verified**. Its co-simulation ladder reads 259 passed, 0 failed, 3 +expected failures on-die and 260 / 0 / 5 off-die. **No hardware has run any +bitstream yet**, so a booting core, a synced display, audible sound and a working +pad are not claimed. The details, and what each rung can and cannot verify, are in [`docs/mister.md`](docs/mister.md).

From 59716116afc9f9e839806c1480661bbbbc3d6d95 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 15 Oct 2026 02:44:25 -0400 Subject: [PATCH 101/104] docs: v3.2.0's test counts from the last full run, in notes and STATUS The release notes said `--features test-roms,commercial-roms` reported 3,659 passed. The last full run on this tree (2026-10-14 20:02, after the final crate change c28cc6d9 at 18:35) reports 3,669 passed, 0 failed, 16 ignored across 176 test binaries; 3,659 was an earlier run's figure. docs/STATUS.md's top-line counts still began at v3.1.0. They now lead with v3.2.0, from the same run: `--features test-roms` 3,418 / 0 / 11 across 166 binaries, with commercial-roms 3,669 / 0 / 16 across 176, and the commercial suites external_real_games 60/0, external_extended 169/0, external_coverage 6/0 (each read from its own `test result` line, not from the notes). The runs used the workspace `test` profile, which is optimized; pass counts do not depend on the profile, so the `--release` wording of the older entries is left as written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 2 +- docs/STATUS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md index d8eebcbf..681d1a40 100644 --- a/.github/release-notes/v3.2.0.md +++ b/.github/release-notes/v3.2.0.md @@ -48,7 +48,7 @@ An unpushed agent branch arrived with 61 audit findings already applied. Nothing ## Verification -- `--features test-roms,commercial-roms`: 3,659 passed, 0 failed, 16 ignored, across 176 test binaries. +- `--features test-roms,commercial-roms`: 3,669 passed, 0 failed, 16 ignored, across 176 test binaries. - The local commercial suites: `external_real_games` 60/0, `external_extended` 169/0, `external_coverage` 6/0 over every staged dump, 25 of them baselined new in this release. - Every clippy, rustdoc, `no_std` and wasm invocation CI runs (23), clean. - AccuracyCoin 146/146, nestest 0-diff, the epoch fingerprint at epoch 4, now recorded as the released epoch. diff --git a/docs/STATUS.md b/docs/STATUS.md index 4a23ae5f..928d659c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1883,7 +1883,7 @@ without panicking (no `$6000` status protocol). | `vrc24test` | — | — | — | — | **Skipped (Track B1)**: link rot. AWJ's original forum attachment (id=10017 on forums.nesdev.org/viewtopic.php?p=203716) is auth-walled; the deletion is documented at archive.nes.science. No GitHub mirror found. | | `AccuracyCoin` | 1 | 1 | — | — | 100thCoin / Chris Siebert single-NROM accuracy battery (MIT license, 151 catalog rows across 22 suites: 146 scored tests + 5 visual-only `Power On State` tests sharing `$03FF`; the v2.0.1 upstream re-sync grew the catalog from 144 to 146 rows / 139 to 141 assigned tests, adding the PPU "ALE + Read" and "Hybrid Addresses" tests, and the **2026-09 re-sync** to upstream `69c8860` grew it again to 149 rows / 144 assigned across 22 suites — two new pages, `Advanced Background Evaluation` and `Advanced Sprite Evaluation`, which re-home eleven existing PPU tests that had outgrown `PPU Misc.`, plus three genuinely new tests, and the **v3.1.0 re-sync** to upstream `f5f41dc2` grew it to 151 rows / 146 scored with two new `CPU Behavior 2` tests, `DMA Landing on Write` and `DMC Reload Timing`). Interactive (D-Pad menu); the harness presses `START` to "run all tests on the ROM" then takes two parallel measurements. **(1) Framebuffer decoder** reads the 10×16 on-screen result grid by exact-pixel colour (5-colour palette: `#64A0FF` = pass, `#4F1000` = fail, `#DC834C` = partial-pass, `#4C4C4C` = no-test / not-run, `#FFFFFF` = border); this is the legacy path and has a known grid-stride bug that under-samples by ~31 cells. **(2) RAM-direct decoder** reads each test's result byte from its fixed CPU-RAM address (catalogued from upstream `AccuracyCoin.asm` in `crates/rustynes-test-harness/src/accuracy_coin_catalog.rs` and `tests/roms/AccuracyCoin/SOURCE_CATALOG.tsv` — 151 `(suite, name, addr)` triples, regenerated by `scripts/accuracycoin-build/extract_catalog.py`) and decodes per-test pass/fail/error-code names + per-suite breakdowns. This is the authoritative path. **Current measured pass rate (RAM-direct): 100.00% (146/146, upstream `f5f41dc2`, v3.1.0)** on the default build. The re-sync's ROM found two defects, fixed in the same change: a DMC load DMA refused by a write took three cycles instead of four (`DMA Landing on Write`), and sprite evaluation took its start from OAMADDR at dot 0 instead of dot 65 and dropped a misaligned OAMADDR after an in-range X (`Misaligned OAM behavior`, which the pre-`f5f41dc2` ROM had recorded as a pass because its fail path returned into the test body without popping the return address). Before that, the two upstream PPU tests "ALE + Read" and "Hybrid Addresses" (briefly the only gaps at 139/141 after the v2.0.1 catalog re-sync) were **closed in v2.0.3** by promoting the 2-cycle-ALE PPU fetch model to the unconditional default. The `90.65%`, `84.17%` and the trajectory figures below are historical engine-lineage milestones (the pre-promotion v1.0.0-rc2 / Session-26 era), retained as history. Historical trajectory: `64.03%` (post-D2 baseline) → `67.63%` (post-D3, 7 6502 bus-pattern fixes) → `69.06%` (post-Phase-3 OAM DMA parity fix, +1 strict test flipped) → `69.78%` (post-FSM-fix recovery, +1 sprite-related sub-test flipped as a side-benefit of the `crates/rustynes-ppu/src/ppu.rs` dot-64 reset removal) → `76.98%` (post-Cascade-B DMC DMA scheduler, commit `9b0c81c` — closes all 8 tests in the `APU Registers and DMA tests` suite + 3 net elsewhere as side-benefits; +11 tests flipped) → `78.42%` (post-Cascade-A OAMADDR-during-rendering reset, commit `f29f7ca` — hardware-accurate per nesdev: OAMADDR is reset to 0 during dots 257-320 of every rendered scanline; +2 tests flipped — Sprite overflow behavior PASSES, Sprite 0 Hit advances from error 1 → error 13) → `79.14%` (post-session-7 OAMADDR-walks-during-eval + $4-aligned `$2004` write, commit `c230489` — closes `Address $2004 behavior` with code 16; +1 net flip) → `79.86%` (post-session-7 RMW ABS,X/Y unfixed-address dummy read, commit `32d5b18` — 18 RMW opcodes get the canonical cycle-4 unfixed-address dummy; flips `APU Tests :: Controller Clocking` and advances `Implied Dummy Reads` 2→3 + `Frame Counter IRQ` 6→7 via the SLO $4015,X bracket; +1 net flip) → `82.73%` (post-session-8 BG-pipeline cycle-9 reload + post-emit shift, commit `086ce4d` — fixes the long-standing 1-column BG pixel off-by-one identified in `docs/audit/cascade-a-investigation-2026-05-19.md`; flips `Sprite 0 Hit behavior` + `Sprite overflow behavior` + `Suddenly Resize Sprite` + `$2007 read w/ rendering`; +4 net flips, +2.87pp) → `83.45%` (post-session-24 Controller Strobing M2-low-defer write, Session-24 Phase 3 — deferred `$4016` commit buffer on `LockstepBus` mirrors Mesen2's `NesControlManager::ProcessWrites`; flips `APU Tests :: Controller Strobing` from `[error 4]` to PASS; +1 net flip) → **`84.17%` (post-session-26 Sprint 2 iter 5 Frame-Counter-IRQ split, 2026-05-23 — separates `FrameCounter::irq_flag` ($4015 bit 6 visibility) from `FrameCounter::irq_line_active` (CPU IRQ source driver) so Tests I/J/K/L/M/N/O all PASS without spuriously asserting the CPU IRQ line on inhibited frame-counter cycles; flips `APU Tests :: Frame Counter IRQ` from `[error 19]` to PASS; +1 net flip)**. Session-26 Sprint 2 iter 4 (APU Register Activation OAM-DMA chip-select gate) advanced the same suite's APU Register Activation entry internally from `[error 4]` to `[error 6]` but did not flip the catalog-headline metric. The previous `75.93%` headline reflected the framebuffer decoder's stride bug, not real accuracy. Strict floor in CI is **60%** — see `crates/rustynes-test-harness/tests/accuracycoin.rs::MIN_PASS_RATE`. the v0.9.x 80% target and the v1.0.0 90% gate were both cleared, and the default build measured **100.00% (144/144)** until the v3.1.0 re-sync (146/146 since) — the master-clock core is the default, the former C1 + sub-cycle residuals are closed, and the two v2.0.1 PPU tests were closed in v2.0.3 (see "Accuracy residuals" below). **No open AccuracyCoin gap, as of v2.6.18.** The 2026-09 re-sync opened two, both in the new `Advanced Sprite Evaluation` page. `Misaligned OAM2 Address` ($0495) closed in v2.6.17 by modelling `OAM2Address` as a live counter through sprite fetch. `Frozen OAM2 Increment` ($0493) closed in **v2.6.18**, and the long diagnosis this row used to carry is **RETRACTED in all three of its claims** — it said a `$2001` enable on dot 256 took effect a dot early (all four writes the ROM names are in force from the START of the dot it names), it blamed test 2 or 3 (the failure was test **4**, the false-positive guard, and the ROM omits an `INC Date: Thu, 15 Oct 2026 03:11:46 -0400 Subject: [PATCH 102/104] docs(release): name v3.1.x as the refused range; count five fixes CodeRabbit on #604 (review 5529394198, two outside-diff findings): - The notes' overview and breaking-changes bullet, and the CHANGELOG's breaking-changes bullet, said movies and netplay "from v3.1.0" are refused. Epoch 4 refuses every epoch-3 build, which is the whole v3.1.x line; the netplay bullet below already said v3.1.x. All three now say v3.1.x. (No v3.1.1 was released, so nothing changes in practice; the wording now matches the contract rather than the one release that exists.) - The overview said "Three emulation defects are fixed" while the Fixed section lists five: the mapper 290 boot and the 24C02 word address across a START were missing from the sentence. It now names all five. Verified: release_notes_render_audit, release_anchor_audit and release_state_prose_audit pass; markdownlint via the commit hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 4 ++-- CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md index 681d1a40..7ab370dd 100644 --- a/.github/release-notes/v3.2.0.md +++ b/.github/release-notes/v3.2.0.md @@ -1,10 +1,10 @@ # RustyNES v3.2.0 — "Confluence" -Every v3.2.0 branch, both repositories, brought into one release. It adds 17 mapper families (191 to 208), among them the Bandai Datach with its barcode reader, and five more families are Curated on local-dump evidence. Three emulation defects are fixed: the VRC4's IRQ mode was read inverted, VRC2 mirroring had settings the chip does not have, and *Fire Hawk* lost its single-screen mirroring on an iNES 1.0 header. An AI-made remediation branch was audited change by change before anything was kept: its byte-identical speed-ups are kept, and measured against the tree it joined: the APU mixes 2-6% faster and an MMC3 read about 9% faster, while its larger claims did not hold and two cartridge reads got slower for a reason not yet found; a 47% audio slowdown it introduced was found and removed; four of the five features it broke are fixed and kept, and Android audio is back on its blocking write, with the non-blocking version a ticket. Netplay moves to protocol 8, libretro save states shrink from 260 KB to about 16 KB, and new save files are owner-only. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line and custom palettes. **Movies and netplay from v3.1.0 are refused, and so are v3.1.x save states of Bandai FCG games.** +Every v3.2.0 branch, both repositories, brought into one release. It adds 17 mapper families (191 to 208), among them the Bandai Datach with its barcode reader, and five more families are Curated on local-dump evidence. Five emulation defects are fixed: the VRC4's IRQ mode was read inverted, VRC2 mirroring had settings the chip does not have, *Fire Hawk* lost its single-screen mirroring on an iNES 1.0 header, mapper 290 did not boot, and a 24C02 EEPROM lost its word address across a START. An AI-made remediation branch was audited change by change before anything was kept: its byte-identical speed-ups are kept, and measured against the tree it joined: the APU mixes 2-6% faster and an MMC3 read about 9% faster, while its larger claims did not hold and two cartridge reads got slower for a reason not yet found; a 47% audio slowdown it introduced was found and removed; four of the five features it broke are fixed and kept, and Android audio is back on its blocking write, with the non-blocking version a ticket. Netplay moves to protocol 8, libretro save states shrink from 260 KB to about 16 KB, and new save files are owner-only. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line and custom palettes. **Movies and netplay from v3.1.x are refused, and so are v3.1.x save states of Bandai FCG games.** ## Breaking changes -- **Movies and netplay from v3.1.0 are refused**: `EMULATION_EPOCH` rises from 3 to 4, because the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes change what the bus sees. Save states are not affected by the epoch. +- **Movies and netplay from v3.1.x are refused**: `EMULATION_EPOCH` rises from 3 to 4, because the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes change what the bus sees. Save states are not affected by the epoch. - **v3.1.x save states of Bandai FCG games are refused** (mappers 16, 153, 157 and 159): their state now carries each serial EEPROM's protocol position, so a state taken mid-transaction resumes it instead of leaving the chip idle, which could make rewind, run-ahead and netplay rollback diverge. Other boards' states are unchanged. - **Netplay protocol 8 (`"RNE8"`)**: lost inputs are resent as one datagram per run of frames. A v3.1.x peer is refused as another emulator version. diff --git a/CHANGELOG.md b/CHANGELOG.md index 09abdf39..396e8dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ cycle-accurate core later replaced. ### Breaking -- **Movies and netplay from v3.1.0 are refused.** `EMULATION_EPOCH` rises +- **Movies and netplay from v3.1.x are refused.** `EMULATION_EPOCH` rises from 3 to 4: the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes below change what the bus sees. A v3.1.x peer or movie is refused as another emulator version, naming its epoch. Save states are not affected by the epoch. From 7de8a4ca756e33c8003614889c757e89bd73d105 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 15 Oct 2026 03:25:04 -0400 Subject: [PATCH 103/104] fix(mappers): a debugger peek no longer disarms the FFE trainer boot agy on #604 (round of 2026-10-15 07:05 UTC). `SystemBus::debug_peek_cpu`, which the debugger's memory view, the disassembler and Lua read through, called the mapper's `cpu_read`. On the FFE boards (mappers 6, 8 and 17, new in v3.2.0) that read is not pure: a trainer ROM boots through a one-shot power-on vector (`boot_vector`, retired by the `$FFFD` read) and a six-byte stub at `$7200` (`stub_live`, retired by its last byte). A peek at the reset vector, or a disassembly across the stub, retired them first, so the CPU then fetched the game's own vector and the trainer never ran. The fix is a new `Mapper::cpu_peek`, defaulting to `cpu_read`, which is right for every board whose CPU read changes nothing, and `debug_peek_cpu` now calls it. The FFE board overrides it as the read with those two fields put back: every other path in its `cpu_read` is a pure lookup, so the peek returns exactly the byte the CPU would read and cannot drift from it. No wrapper board stands between the bus and an FFE mapper (the `inner` delegators all wrap concrete MMC3/VRC types), so dispatch reaches the override. `Mapper::ppu_peek` was made the PPU-side equivalent earlier in this release; this is its CPU half for the one board family that needs it. Other boards with impure CPU reads that predate v3.2.0 are unchanged by the default. Tests, both written red first: - `ffe_super_magic_card::a_debugger_peek_leaves_the_trainer_boot_armed`: peeks of `$FFFC`/`$FFFD` and the whole stub return the CPU's bytes and leave `save_state()` identical; the CPU's reads afterwards still take the stub. It failed against the default `cpu_peek`. - `nes::debug_peek_cpu_leaves_an_ffe_trainer_boot_armed`: the same through a parsed iNES image and the real bus. Mutation: routing `debug_peek_cpu` back to `cpu_read` makes it fail (CAUGHT). Also in this commit: - CHANGELOG and release notes, Breaking: `Mapper` gained `ppu_peek` as a REQUIRED method in this release, which breaks a mapper implemented outside this repository, and neither document said so. The entry also lists the seven methods added with defaults (`cpu_peek`, `may_unmap_prg_rom`, `is_save_data_dirty`, `clear_save_data_dirty` and the three barcode-reader methods), from a diff of the trait's method list against v3.1.0. - `branch_contained.py` counts lines instead of testing set membership (agy, same round): a branch adding a second copy of a line `main` already had once read as contained. Now `main` must hold each added line at least as many times as the branch's own copy of the file does. Shown on a scratch repository: the old form passed the unmerged duplicate (exit 0), the new one reports it missing (exit 1) and passes once it is merged. The uncommitted-binary read uses `with` (agy's nit). - VERSION-PLAN's v3.2.0 row said the bitstreams are at seed 2; seed 4. Verified: cargo test -p rustynes-mappers -p rustynes-core 1,362 passed, 0 failed; clippy -D warnings on both crates; rustdoc -D warnings; fmt; ruff check + format; the three release audits pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- .github/release-notes/v3.2.0.md | 1 + CHANGELOG.md | 9 ++++++ VERSION-PLAN.md | 2 +- crates/rustynes-core/src/bus.rs | 6 ++-- crates/rustynes-core/src/nes.rs | 26 +++++++++++++++ .../src/ffe_super_magic_card.rs | 32 +++++++++++++++++++ crates/rustynes-mappers/src/mapper.rs | 15 +++++++++ .../release-automation/branch_contained.py | 31 ++++++++++++++---- 8 files changed, 112 insertions(+), 10 deletions(-) diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md index 7ab370dd..8f2861bf 100644 --- a/.github/release-notes/v3.2.0.md +++ b/.github/release-notes/v3.2.0.md @@ -7,6 +7,7 @@ Every v3.2.0 branch, both repositories, brought into one release. It adds 17 map - **Movies and netplay from v3.1.x are refused**: `EMULATION_EPOCH` rises from 3 to 4, because the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes change what the bus sees. Save states are not affected by the epoch. - **v3.1.x save states of Bandai FCG games are refused** (mappers 16, 153, 157 and 159): their state now carries each serial EEPROM's protocol position, so a state taken mid-transaction resumes it instead of leaving the chip idle, which could make rewind, run-ahead and netplay rollback diverge. Other boards' states are unchanged. - **Netplay protocol 8 (`"RNE8"`)**: lost inputs are resent as one datagram per run of frames. A v3.1.x peer is refused as another emulator version. +- **The `Mapper` trait requires `ppu_peek`** (a side-effect-free PPU read for the debugger), so a mapper implemented outside this repository must add it. `cpu_peek`, `may_unmap_prg_rom`, `is_save_data_dirty`, `clear_save_data_dirty` and three barcode-reader methods are added with defaults. ## Fixed diff --git a/CHANGELOG.md b/CHANGELOG.md index 396e8dfa..ecc74ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,15 @@ cycle-accurate core later replaced. per run of frames instead of one per frame. A v3.1.x peer cannot read that message, so its `"RNE7"` is refused as another emulator version (its epoch would refuse it anyway). +- **The `Mapper` trait requires `ppu_peek`**, a side-effect-free PPU read the + debugger uses on every board, with no default (a default would show a + board that forgot it as a blank pattern table), so an implementation + outside this repository must add it. Seven methods are added with defaults + that keep the old behaviour: `cpu_peek` (the debugger's CPU read; the FFE + boards override it so a peek cannot retire their trainer boot), + `may_unmap_prg_rom`, `is_save_data_dirty`, `clear_save_data_dirty` and + the Datach's `has_barcode_reader`, `scan_barcode` and + `barcode_scan_in_progress`. ### Added diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md index 01341e24..d96c3e23 100644 --- a/VERSION-PLAN.md +++ b/VERSION-PLAN.md @@ -165,7 +165,7 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide | **v2.7.6 "Recount"** | The v2.7.5 deletions measured one at a time -- a measurement release between the v2.7.x and v2.8.x audit lines. v2.7.5 bounded six of the core audit's proposals (§3.1 A/B/C, the IMP-06 stores, §3.5b, §3.6) only in one combined ceiling; each was re-run alone, twice, with the A/B/A control. Five are zero; §3.5b had never been reached (the bench ROMs are silent, 0 sweep-mute checks per frame), and on `spritecans.nes` bounds at about 0.2%, with the cheap byte-identical candidate 0.67% slower, so it is rejected. Three of the IMP-06 stores re-wrote fields the fast path's guard already requires, and are now a `debug_assert!` pinned by a unit test (no ROM reaches the violating state). §3.1 B and C are dead work that goes with the v2.9.0 deprecation decision. Also ships #554 (contributed): the macOS buildbot jobs build again, and 32-bit Windows, 32-bit Linux and webOS targets were added. Plan: `to-dos/plans/v2.7.6-recount-plan.md` | | **v2.8.0 "Bulkhead"** | The libretro core stops a fault at its own boundary -- the first release of the v2.8.x audit line, from the libretro audit's FFI-safety findings (L-1.1 to L-1.5, L-2.1, L-2.2, L-2.4), each shown failing in a new C-ABI harness that drives the core as a frontend does. The core was built `panic = "abort"`, so its handlers were dead code; it now unwinds on every supported build path (+0.1% instructions per frame; a direct `cargo build --release` still aborts, and now warns and logs it), and every emulating callback contains a panic, stops the game and keeps the console so RetroArch's memory pointers stay valid. `serialize_size` reserves room for the largest expansion device on both ports (a Zapper no longer breaks every later save) and the reader treats an all-zero tail as padding; unload withdraws the memory maps; the vendored `rust-libretro-sys` writes out `retro_game_info`, so the core loads without `GET_GAME_INFO_EXT`. The save state gains the 2A03 internal data bus (older states still load), and the schema audit now covers the bus. No emulation behaviour change; AccuracyCoin 144/144, nestest 0-diff. Plan: `to-dos/plans/v2.8.0-bulkhead-plan.md` | | **v3.0.1 "Mortar"** | A maintenance release on v3.0.0, plan [`v3.0.1-mortar-plan.md`](to-dos/plans/v3.0.1-mortar-plan.md). Mapper 45's CHR-RAM is unbanked, so *Famicom Yarou Vol.1* draws its menu (T-GA23C-CHRRAM; `EMULATION_EPOCH` 2 refuses v3.0.0 movies and netplay peers). The MiSTer core's dot-0 A12 rule is gated on whether cycle 0 was rendering, found by a new odd-frame stimulus (`mapper4mmc3oddskip080`, plus an /IRQ-rise comparison); new bitstreams at seed 2 / 261007, a release candidate, not hardware-verified. Rust 1.99 and every dependency at its newest, the libretro buildbot included once test pipeline 119614 passed 15/15. Every bot review left unanswered since PR #1 answered (473 verdicts, 80 fixes). The shared Bisqwit NTSC pass recorded as derived; the TriCNES source moved out of the repository. The plan to v4.0.0 written from 29 maintainer decisions | -| **v3.2.0 "Confluence"** (current) | The second release of the v3.1 to v4.0 line, plan [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md), and every v3.2.0 branch in both repositories brought into one. 17 mapper families (191 to 208, among them the Bandai Datach with its barcode reader), 47/121/154/191/243 promoted to Curated, tiers 51 / 130 / 27 (181 of 208 accuracy-gated). The VRC4 IRQ mode bit is read the right way round, VRC2 mirroring has its two settings, *Fire Hawk* keeps single-screen mirroring on an iNES 1.0 header, mapper 290 boots and the 24C02 keeps its word address across a START (`EMULATION_EPOCH` 4: v3.1.x movies and peers are refused). An AI remediation branch (61 findings) audited change by change: 20 kept, 14 fixed, 4 reverted, 10 rejected, its APU speed-ups kept byte-identical and the 47% expansion-audio slowdown it introduced removed. Netplay protocol 8 (`"RNE8"`), libretro states of about 16 KB, owner-only save files. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line (on-die) and custom palettes, moves its pin to v3.2.0 (842 golden artifacts reproduce byte for byte), and ships bitstreams at fitter seed 2. **No hardware has run any bitstream.** | +| **v3.2.0 "Confluence"** (current) | The second release of the v3.1 to v4.0 line, plan [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md), and every v3.2.0 branch in both repositories brought into one. 17 mapper families (191 to 208, among them the Bandai Datach with its barcode reader), 47/121/154/191/243 promoted to Curated, tiers 51 / 130 / 27 (181 of 208 accuracy-gated). The VRC4 IRQ mode bit is read the right way round, VRC2 mirroring has its two settings, *Fire Hawk* keeps single-screen mirroring on an iNES 1.0 header, mapper 290 boots and the 24C02 keeps its word address across a START (`EMULATION_EPOCH` 4: v3.1.x movies and peers are refused). An AI remediation branch (61 findings) audited change by change: 20 kept, 14 fixed, 4 reverted, 10 rejected, its APU speed-ups kept byte-identical and the 47% expansion-audio slowdown it introduced removed. Netplay protocol 8 (`"RNE8"`), libretro states of about 16 KB, owner-only save files. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line (on-die) and custom palettes, moves its pin to v3.2.0 (842 golden artifacts reproduce byte for byte), and ships bitstreams at fitter seed 4 (build date 261014). **No hardware has run any bitstream.** | | **v3.1.0 "Bellwether"** | The first release of the v3.1 to v4.0 line, plan [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md). AccuracyCoin re-synced to upstream `f5f41dc2`, 146 of 146; the new ROM showed that every earlier 100% included a `Misaligned OAM behavior` failure the old ROM hid, and both defects it found are fixed (`EMULATION_EPOCH` 3, BUS section 3, `PPU_SNAPSHOT_VERSION` 13, `.rnm` 6, netplay 7: v3.0.1 states, movies and peers are refused). The CPU overclock and the sprite-limit option work and ride in `HardwareOptions`; PAL and Dendy emphasis swap red and green; the alternate MMC3 is selectable and asserts on a `$C001` reload to 0; differential phase in the NTSC decode; rewind and run-ahead on the Vs. cabinet; an epoch fingerprint gate. The MiSTer core matches the re-synced battery on all 146 entries (the misaligned X mask, the DMC reload edge), matches the emulator on a `$2006` copy landing on every dot (RTL-1, provisional until a board settles it), gates 30 of 34 AccuracyCoin sub-test ROMs, and moves its pin to v3.1.0. **No hardware has run any bitstream.** | | **v3.0.0 "Cornerstone"** | The API major -- the first release of the v3 line (ADR 0043). It gathers every break since v2.x in one place (the header-free game identity, refused older states and movies, the protocol and API changes of v2.8.x-v2.9.9) and adds the last: a core timing epoch that movies (`.rnm` format 5) and netplay (protocol 6, `"RNE6"`) carry, so a version that emulates differently is refused with a reason (ADR 0045); `Cartridge`, `BoardDescription`, `HardwareOptions` and `Movie` `#[non_exhaustive]`; `MapperError::Truncated` renamed `WrongLength`. T-MMC3-BG-A12 closes blargg's `4-scanline_timing` (13/13) in the emulator and on the MiSTer core (`PPU_SNAPSHOT_VERSION` 12). Also: the spectator buffer bounded, mapper 45's power-on CHR-AND (two *Famicom Yarou* menus), the mobile apps on the workspace version, and `CI success` that cannot pass untested. The MiSTer bitstreams ship as a release candidate, not hardware-verified; verification moves to v3.x | | **v2.9.9 "Ballast"** | The release candidate for v3.0.0 -- the tenth release of the v2.9.x line and the last of the line to v3.0.0. All four audit scopes re-run (0 rows regressed; NC-09..17, NF-11..23, NL-11..15, NR-13..17 fixed or decided, NC-17 and NR-14 by the maintainer). T-ORACLE-001 fixed to `4-scanline_timing` sub-test 9: the oracle's `$C001` reload discriminator was the late IRQ; the NESdev rule plus a one-cycle IRQ output deferral replace it (MMC3 section v4). T-MMC5-8X8-SET fixed and wider than the ticket: the MMC5 decodes `$2000`/`$2001` itself and follows the page's CHR table and `$2007` rule, which also fixed *Uchuu Keibitai SDF* (T-COMMERCIAL-GARBLE closed; MMC5 section v6). Audio exact across a save state (APU v5, NL-12). Movie format 4 (NC-10). Mobile save keys migrated to the v2.9.8 identity (NF-21). The sibling's oracle pin moved v2.9.2 -> v2.9.9 with every changed golden attributed, the APU `$4017` / scanline-0 parity in RTL, a frame-sequencer stall found and fixed, and `apu_test` 1-10, `mapper4mmc3irq065` and four checkpoint streams gated. RC bitstreams at seed 6 (261005), byte-identical double compiles. T-GA23C-POWERON searched and left open. | diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs index 721127c2..6c11bf72 100644 --- a/crates/rustynes-core/src/bus.rs +++ b/crates/rustynes-core/src/bus.rs @@ -1766,8 +1766,10 @@ impl SystemBus { 0x4000..=0x4014 | 0x4018..=0x401F => self.open_bus, 0x4020..=0xFFFF => { // Mirror the production read path so the debugger hex viewer - // shows the Game-Genie-substituted byte the CPU would see. - let raw = self.mapper.cpu_read(addr); + // shows the Game-Genie-substituted byte the CPU would see, + // through `cpu_peek` so a board whose read consumes state + // keeps it (v3.2.0: the FFE trainer boot). + let raw = self.mapper.cpu_peek(addr); self.apply_genie(addr, raw) } } diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs index d511f2db..c2553007 100644 --- a/crates/rustynes-core/src/nes.rs +++ b/crates/rustynes-core/src/nes.rs @@ -3940,6 +3940,32 @@ mod tests { assert!(nes.add_genie_code("BADCODE!").is_err()); } + #[test] + fn debug_peek_cpu_leaves_an_ffe_trainer_boot_armed() { + // Mapper 6 (FFE) with a trainer boots through a one-shot stub at + // $7200 that the CPU's own reads retire. The debugger, disassembler + // and Lua read through `debug_peek_cpu`, which must reach the board's + // `cpu_peek`: through `cpu_read` it retired the stub, and the game + // then never ran its trainer (agy on #604). + let mut rom = Vec::new(); + rom.extend_from_slice(b"NES\x1A"); + rom.extend_from_slice(&[16, 0, 0x64, 0x00]); // 256 KiB PRG, mapper 6, trainer + rom.extend_from_slice(&[0u8; 8]); + let mut trainer = vec![0u8; 512]; + trainer[0] = 0x4C; // JMP, at $7000 + rom.extend_from_slice(&trainer); + rom.extend_from_slice(&vec![0u8; 256 * 1024]); + let mut nes = Nes::from_rom(&rom).expect("synthetic FFE ROM parses"); + let stub: Vec = (0..6u16) + .map(|i| nes.bus_mut().debug_peek_cpu(0x7200 + i)) + .collect(); + assert_eq!(stub[0], 0x20, "the stub begins JSR $7003"); + let again: Vec = (0..6u16) + .map(|i| nes.bus_mut().debug_peek_cpu(0x7200 + i)) + .collect(); + assert_eq!(again, stub, "a debugger peek retired the boot stub"); + } + #[test] fn poke_ram_writes_system_ram_and_ignores_rom() { let rom = synth_nrom(16, 8); diff --git a/crates/rustynes-mappers/src/ffe_super_magic_card.rs b/crates/rustynes-mappers/src/ffe_super_magic_card.rs index a0d4f053..815d8339 100644 --- a/crates/rustynes-mappers/src/ffe_super_magic_card.rs +++ b/crates/rustynes-mappers/src/ffe_super_magic_card.rs @@ -504,6 +504,18 @@ impl Mapper for SuperMagicCard { } } + /// The read's only side effects are the trainer boot's two one-shots + /// (`boot_vector` retired by the `$FFFD` read, `stub_live` by the stub's + /// last byte); every other path above is a pure lookup. So the peek is + /// the read with those two put back, which returns exactly the byte the + /// CPU would read and cannot drift from `cpu_read`. + fn cpu_peek(&mut self, addr: u16) -> u8 { + let (vector, stub) = (self.boot_vector, self.stub_live); + let v = self.cpu_read(addr); + (self.boot_vector, self.stub_live) = (vector, stub); + v + } + fn cpu_write(&mut self, addr: u16, value: u8) { match addr { // The FDS RAM adapter under the card. @@ -1051,6 +1063,26 @@ mod tests { assert_ne!(m.cpu_read(0xFFFD), 0x70); } + #[test] + fn a_debugger_peek_leaves_the_trainer_boot_armed() { + // The debugger, the disassembler and Lua read through `cpu_peek`. + // Peeking every byte the trainer boot consumes must return what the + // CPU would read and leave the boot exactly as armed, so the CPU's + // own reads afterwards still take the stub (agy on #604). + let mut m = board(6, 16, 0, None, true); + let before = m.save_state(); + assert_eq!((m.cpu_peek(0xFFFC), m.cpu_peek(0xFFFD)), (0x00, 0x72)); + let stub: Vec = (0..6).map(|i| m.cpu_peek(0x7200 + i)).collect(); + assert_eq!(stub, STUB, "a peek shows the stub"); + assert_eq!(m.save_state(), before, "a peek changed the board"); + assert_eq!(m.cpu_read(0xFFFD), 0x72, "the CPU still boots the stub"); + let stub: Vec = (0..6).map(|i| m.cpu_read(0x7200 + i)).collect(); + assert_eq!(stub, STUB); + // Once the CPU has retired both, a peek sees the game's bytes. + assert_eq!(m.cpu_peek(0xFFFD), 0x12); + assert_eq!(m.cpu_peek(0x7200), 0); + } + #[test] fn state_round_trips() { let mut a = board(17, 16, 16, None, true); diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs index 5930cdd0..2611cd5d 100644 --- a/crates/rustynes-mappers/src/mapper.rs +++ b/crates/rustynes-mappers/src/mapper.rs @@ -213,6 +213,21 @@ pub trait Mapper: Send { /// Read a byte from the CPU address space `$4020-$FFFF`. fn cpu_read(&mut self, addr: u16) -> u8; + /// Read a CPU byte (`$4020-$FFFF`) for the debugger, the disassembler + /// and scripts, which must never change the game. + /// + /// `SystemBus::debug_peek_cpu` calls this rather than + /// [`Self::cpu_read`]. The default IS `cpu_read`, which is right for every + /// board whose CPU read changes nothing; a board whose read consumes + /// state overrides it to return the same byte and keep the state. Added + /// in v3.2.0 for the FFE boards (6, 8, 17), whose trainer boot is a + /// one-shot that the CPU's reset-vector and stub reads retire: a debugger + /// peek at `$FFFD` or a disassembly through the stub used to retire it + /// first, so the trainer never ran (agy on #604). + fn cpu_peek(&mut self, addr: u16) -> u8 { + self.cpu_read(addr) + } + /// Write a byte to the CPU address space `$4020-$FFFF`. fn cpu_write(&mut self, addr: u16, value: u8); diff --git a/scripts/release-automation/branch_contained.py b/scripts/release-automation/branch_contained.py index 3fa456c1..1c531212 100755 --- a/scripts/release-automation/branch_contained.py +++ b/scripts/release-automation/branch_contained.py @@ -23,9 +23,13 @@ same path on `main` (a substring test would let `return value` pass against `return value + 1`). Lines shorter than 8 characters (braces, `end`, blank-ish lines) are skipped as uninformative. This is a containment heuristic, not a -proof: a line moved to a different file reads as missing (a false alarm, safe), -and a line that also occurs elsewhere in the file reads as present. Read every -MISSING line before deleting anything. +proof: a line moved to a different file reads as missing (a false alarm, safe). +Lines are counted, not just looked up: `main` must hold each added line at +least as many times as the branch's own copy of the file does, so a branch +that adds a second copy of a line `main` already has once reads as missing +(the first form tested set membership and passed it; agy on #604). A line +`main` removed on its own reads as missing too, which is the safe direction. +Read every MISSING line before deleting anything. The exact-line, binary and untracked-contents rules were added after `CodeRabbit` on #604 and #605 found the first version weaker than this @@ -48,7 +52,7 @@ import os import subprocess import sys -from collections import defaultdict +from collections import Counter, defaultdict MIN_LEN = 8 SHOW_MISSING = 12 @@ -122,10 +126,19 @@ def main() -> int: total = missing = 0 for path, texts in sorted(lines.items()): on_main = git(a.path, "show", f"{a.main}:{path}", check=False).stdout - main_lines = {ln.strip() for ln in on_main.splitlines()} + main_count = Counter(ln.strip() for ln in on_main.splitlines()) + # The branch's own copy: the file on disk in a worktree (which holds + # the uncommitted lines too), else the branch's committed blob. + disk = os.path.join(a.path, path) + if checked_out and os.path.isfile(disk) and not os.path.islink(disk): + with open(disk, encoding="utf-8", errors="replace") as f: + ours = f.read() + else: + ours = git(a.path, "show", f"{branch}:{path}", check=False).stdout + our_count = Counter(ln.strip() for ln in ours.splitlines()) for text in texts: total += 1 - if text not in main_lines: + if main_count[text] < max(our_count[text], 1): missing += 1 if missing <= SHOW_MISSING: print(f" MISSING {path}: {text[:110]}") @@ -143,7 +156,11 @@ def main() -> int: for path in uncommitted_bin: binaries += 1 disk = os.path.join(a.path, path) - here = open(disk, "rb").read() if os.path.isfile(disk) else None + if os.path.isfile(disk): + with open(disk, "rb") as f: + here = f.read() + else: + here = None if here != blob(a.path, f"{a.main}:{path}"): missing += 1 print(f" MISSING (binary, uncommitted) {path}") From 0ee43e3e53ad176a43910cea7dc403fa73c60c88 Mon Sep 17 00:00:00 2001 From: DoubleGate Date: Thu, 15 Oct 2026 03:26:41 -0400 Subject: [PATCH 104/104] docs(roadmap): ticket the two follow-ups #604's last agy round left T-M116-STATE-SLIM: mapper 116 builds a whole Mmc1 for its shift register and four registers, so each state, rewind frame and netplay snapshot carries about 10 KiB of unused MMC1 CHR-RAM and VRAM. A register-only core fixes it but changes 116's state layout, so it is not done at the release cut; v3.3.0. T-SAVE-DIRTY-CLEAR: the flash boards' is_save_data_dirty shortcut goes unused once a .sav is loaded or written, because no frontend calls clear_save_data_dirty. Saves are safe either way (the byte comparison resumes); this is performance only; v3.3.0. Both were declined as defects in the PR reply and promised a ticket there; this is that ticket. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj --- to-dos/ROADMAP.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/to-dos/ROADMAP.md b/to-dos/ROADMAP.md index 390148d8..dd2f7ccd 100644 --- a/to-dos/ROADMAP.md +++ b/to-dos/ROADMAP.md @@ -1495,6 +1495,8 @@ and the sibling's integration. Slots are proposals for the maintainer. | `T-IOS-DURABLE-WRITE` | iOS persistence uses `.atomic` writes with no `fsync` of the file or its directory (FDS saves, battery, overrides, movies, HD packs, palettes): one shared durable-write helper, the Swift counterpart of the desktop's `atomic_write.rs` | #605's review | v3.6.0 | | `T-IOS-FDS-PERIODIC` | iOS flushes an FDS disk only on backgrounding and game switches; the battery timer exists only for battery games. A once-a-second disk check like Android's (added in #605's review) | #605's review | v3.6.0 | | `T-XS-M185-CHRCS` | The MiSTer core's extra-sprite port ignores mapper 185's CHR chip-select (visible only with the >8-sprite option, CHR disabled and rendering on). Match the oracle's behaviour by a black-box stimulus first | sibling #59's review | a MiSTer F-phase release | +| `T-M116-STATE-SLIM` | Mapper 116 (Somari) builds a whole `Mmc1` only for its shift register and four registers, so every state, rewind frame and netplay snapshot carries about 10 KiB of that MMC1's unused CHR-RAM and VRAM. A register-only MMC1 core, as `Mmc3::register_core` is for its MMC3 half; it changes 116's state layout, so it rides a release that may break it | #604's review (agy) | v3.3.0 | +| `T-SAVE-DIRTY-CLEAR` | The flash boards' `is_save_data_dirty` shortcut goes unused after a `.sav` load or the first write, because no frontend calls `clear_save_data_dirty`: call it after attaching a save and after a write known to be current (safe either way; performance only) | #604's review (agy) | v3.3.0 | | `T-SURVEY-UNTICKETED` | Triage the 25 items of the line plan's oracle survey that no plan, ticket or CHANGELOG entry names (ACC-11/12/14, AV-01..03, CI-03/06, CR-04/05, DOC-10, LR-01/05/06/07, MAP-04/06, NET-05, PERF-01..03, PROV-01..04; PROV-01 shipped as `T-NTSC-PROVENANCE` under its ticket name): ticket each, or record why not. The survey lived only in session scratch until v3.2.0 | [`plans/research/v3.1-line-survey-oracle-backlog.md`](plans/research/v3.1-line-survey-oracle-backlog.md) | v3.3.0 | ## Open questions blocking planning