Skip to content

v2.9.8 "Vanguard" -- v3.0.0's breaks landed early, every staged game looked at, and the database's corrections on every platform - #579

Merged
doublegate merged 61 commits into
mainfrom
feat/v2.9.8-cadence
Oct 3, 2026
Merged

doublegate merged 61 commits into
mainfrom
feat/v2.9.8-cadence

Conversation

@doublegate

@doublegate doublegate commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

v2.9.8 "Vanguard": the preparation release for v3.0.0

The ninth release of the v2.9.x line. It began as the performance and MiSTer-menu release. At the maintainer's direction it became the preparation release for v3.0.0:

  • v3.0.0's planned breaking changes land here;
  • every staged game was booted and looked at;
  • the game database's corrections now reach every platform.

The number stays v2.9.x by the maintainer's decision. ADR 0042 and ADR 0043 are amended accordingly, and v3.0.0's notes will restate every break.

Breaking changes

  • Save identity. Nes::rom_sha256 now hashes the bytes after the 16-byte header, so earlier cartridge saves, states, cheats and movies are not found. Nes::image_sha256 is the whole-file hash, and the Vs. database is keyed by both.
  • States. .rns container epoch 3 and BUS section 2 refuse every older state, and every legacy reader is removed.
  • Movies and netplay. .rnm format 3 and the netplay handshake carry every emulation option (ADR 0044), and the Four Score's P3/P4.
  • API. LockstepBus is now SystemBus. The 2.7.5 deprecations, serialize_header and the dead /NMI detector are removed. Header and FrameInput are #[non_exhaustive].

Fixes from booting every one of 748 staged dumps

  • The game database was rewriting correct NES 2.0 headers (10 games).
  • The iNES 1.0 dirty-tail mapper nibble.
  • PAL regions now reach iNES 1.0 dumps.
  • Mappers 19, 64, 78, 105 ($4017 inhibit), 153, 191 (non-power-of-two images), 218 and 226.
  • Vs. work RAM and the Goonies palette.
  • VRC6 power-on CHR banks.

Each fix has a test that failed first, and reverting the fix makes it fail again. Each changed baseline was looked at and attributed, one of them by bisect.

Every platform

  • The database's corrections now reach Android, iOS and libretro, through one shared path.
  • Power-on options apply before a game's first frame (a real race on game-to-game loads).
  • The core's power cycle keeps the PPU and APU settings.
  • The Vs. DualSystem cabinet is built on every desktop load path, and Power Cycle covers the cabinet.
  • An opt-in Famicom console model.
  • The documented NTSC emphasis model.
  • Rebased onto fix(ios): the iOS app builds, launches and plays -- plus release-time iOS CI #578, the iOS app fix, whose entries ship in this release.

Performance (two ab_check.sh runs per claim; docs/performance.md)

  • The /NMI removal: −4.9% to −6.1% on three workloads.
  • Three of eleven re-measured candidates adopted, −1.8% to −4.3% together.
  • End to end against v2.9.7, only the shipped nestest_fast path is established (−5% to −9%). The palette workloads do not add up, and that is recorded as a v2.9.9 lead rather than claimed.

MiSTer core (sibling PR)

  • Aspect ratio, integer scaling, crop and a 2C03-palette menu option, compiled and timing-closed but not seen on hardware.
  • A palette-gate, the first gate to check the RTL's palette output.
  • Seeds re-swept, with seed 2 kept.
  • No hardware has run any bitstream.

Review

CodeRabbit cannot review a PR this size: 180 reviewable files, against its limit of 100. It was run on two review-only slices of this branch instead, #580 (the core crates) and #581 (the rest). Both will be closed unmerged. 25 findings were real and are fixed here, each with a test that failed first:

  • a refused movie changed the running game;
  • mapper corrections were lost on "DiskDude!" headers;
  • a $4017 inhibit was let through inside the reset delay;
  • netplay v4 and v5 peers could half-sync;
  • Android netplay had its own stale emphasis copy;
  • history-viewer exports spanned an options change;
  • TAStudio drove only two ports and exported late options;
  • Power Cycle read a .pal file under the emu lock;
  • several docs described the code wrongly.

Six findings cited code in the other slice, and are answered with this branch's code. Antigravity's round 2 found the desktop reporting "State loaded" for a refused state; that is fixed. Its SemVer and save-identity blockers are the maintainer's decisions, and are declined with the reasons.

Verification

  • cargo test --release --workspace --features test-roms: 3,148 passed, 0 failed, 14 ignored (head before the counts commit, 2fa43b8).
  • With commercial-roms too: 3,367 / 0 / 19, the full coverage sweep included.
  • AccuracyCoin 144/144, nestest 0-diff.
  • fmt, clippy for every feature set, both wasm builds, rustdoc, the no_std build, markdownlint and the release audits are clean.
  • Co-simulation: 175/0/1 on-die and 176/0/1 off-die.

The iOS Swift and the device runs are not verified on this Linux host.

The merge waits for the maintainer.

🤖 Generated with Claude Code

doublegate and others added 30 commits October 2, 2026 09:38
The plan for v2.9.8 "Cadence" (to-dos/plans/v2.9.8-cadence-plan.md) and
the first item it closes. The maintainer decisions (2026-09-30):
- codename Cadence;
- S2 stays after v3.0.0;
- all three MiSTer menu options;
- libnx dropped with an explicit error.

libnx: the crate Makefile mapped `platform=libnx` to
aarch64-nintendo-switch-freestanding. That is a tier-3 target with no std,
while this wrapper needs std::fs (FDS BIOS, disk saves) and catch_unwind
(the panic-containment design). rustup ships no rust-std for it, and the
libretro buildbot has no Rust Switch template and never reads the Makefile
table, so nothing shipped from it. It is now a `$(error ...)` rather than a
deleted branch: deleting it would let `platform=libnx` fall through to the
native branch and emit a HOST library under the Switch's name. The same
branch's `EXT := a` routing is removed. Make needs the error line indented
with spaces; a tab made it a recipe line ("recipe commences before first
target").

Pinned by `platform_libnx_is_refused_not_built_for_the_host` in
libretro_makefile_audit: the make dry run must fail with the message.
Removing the error line fails it (CAUGHT), because the dry run then
succeeds as a host build.

Records:
- The line plan's S2 row carried v2.6.16 figures ("24/24" PRG, CHR "23
  against a budget of 22"). Since v2.9.1 put CHR in its own bank, the worst
  cases are CHR 18/22 and PRG 22/24, and the CHR gate's threshold was
  always 31. The row is corrected and moved after v3.0.0.
- VERSION-PLAN's v2.9.8 row is updated.
- docs/libretro/architecture.md item 5 and the L-3.2 ledger row record the
  drop.
- CHANGELOG [Unreleased] carries a Removed entry.

The second palette is 2C03 RGB, not palette_gen.rs: that generator's
Provenance header discloses a derived region, which ADR 0037 makes a black
box for HDL, and the plan records why.

Verified: all 12 audit tests pass; libretro_makefile_audit 6/6; fmt and
clippy; markdownlint on every changed document; `make -n platform=unix` and
`platform=windows ARCH=x86_64` recipes unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ODEL)

Found because the MiSTer core's first palette-gate (v2.9.8) compared the
two projects' colours and found 374 of 1,024 emphasised entries different.
Neither side followed the hardware:
- this emulator dimmed the other two RGB channels to 13/16 per set bit,
  compounding, so three bits gave an even 0.66 dim of everything;
- the core dimmed each channel once to 3/4.

NESdev's NTSC_video page documents one attenuator, shared by the three
bits, armed on the phases of colours $C, $4 and $8 ("6 out of 12 half-clocks
if one bit is set, 10 ... if two, all 12 if all three"), and never on
columns $E/$F. The maintainer asked for a better, documented model shared by
both before v3.0.0, in v2.9.8 since v2.9.9 is the release candidate.

emphasis.rs is the model, written from the page's prose and data:
- lidnariq's terminated plain and attenuated levels;
- the "Color Phases" diagram as (p + n) mod 12 in 1..=6, checked row by row
  against the transcribed diagram;
- the attenuator phases;
- a twelve-sample decode with the burst on -U;
- the page's YUV-to-RGB matrix.

It is not from palette_gen.rs, a disclosed derived region, and not from
the page's illustrative C++, which is CC BY-SA example code. Only the CHANGE
is used: EMPHASIS_DELTA[(emph << 6) | colour] is the model's emphasised RGB
minus its plain RGB, added to the FBX base (or a loaded .pal) and clamped.
A difference, not a ratio, because the model's darkest colours decode at or
below black, where a ratio means nothing. With no emphasis every delta is
0, so un-emphasised frames are byte-identical.

The table is committed data (emphasis_delta_table.rs, generated by an
ignored test) because build_rgba_lut is a const fn and the decode needs
sin/cos. `the_committed_table_is_the_documented_model` recomputes all 512
entries with libm, so the two cannot drift. The model functions are
test-only in a non-test build, under a scoped dead_code allow saying so.

apply_emphasis (13/16) is deleted rather than deprecated: `palette` is a
private module and the function was never re-exported, so it was not public
API.

Tests: documented properties, each a unit test:
- the phase rule equals the diagram;
- $x6/$xA/$x2 decode red/green/blue;
- $xE/$xF and emphasis 0 are untouched;
- three bits darken with channels within 1 of each other;
- one bit tints toward its own channel.

Mutations, all CAUGHT, each by at least two tests:
- no $E/$F exemption;
- bit 5 on colour 4's phases;
- the burst on +U;
- attenuated levels equal to plain.

Goldens re-blessed, 13, each attributed:
- test-roms: visual_regression full_palette 60/180 and flowing_palette
  60/180/300, m22 vrc2a chr banking, and the unreferenced-corpus pin for
  ppu_palette.nes. A scratch probe counted emphasised pixels in the index
  framebuffer at each pinned frame: 48,896, 49,664, 61,440 and 16,384 of
  61,440.
- commercial (local, snapshots only): extended m112 / m221 / Felix the Cat,
  and coverage Felix / Cobra Triangle / m112. In every one, only
  fb_fnv1a64 changed; cycles and the audio hash are identical, so the
  emulation is untouched and only emphasised colours moved.
- real_games 60/0 unchanged.

Verified:
- test-roms 3,067 passed before re-blessing, with the 7 failures all
  attributed above (AccuracyCoin and nestest among the passes);
- fmt; workspace clippy -D warnings; rustdoc -D warnings;
- the no_std thumbv7em build.

Docs: ppu-2c02.md "Greyscale + emphasis" describes the model; ROADMAP gains
T-EMPHASIS-MODEL; CHANGELOG [Unreleased] Changed.

Raised, not changed: docs/ppu-2c02.md calls raw_signal.rs "the canonical
Bisqwit nes_ntsc / Mesen2 'raw palette' generator", but that file has no
Provenance header or record row. It was not read, and this model does not
use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
docs/ppu-2c02.md has described crates/rustynes-ppu/src/raw_signal.rs since
v2.1.9 as "the canonical Bisqwit nes_ntsc / Mesen2 'raw palette'
generator", yet the file carried no `// Provenance:` header and no row in
docs/originality-and-provenance.md section 1, so provenance_record_audit,
which keys on headers, could not see it. Found while the v2.9.8 emphasis
model was being written from NESdev (that model does not use this module,
and the module was not read). The maintainer chose to attribute it, as for
mapper 250: header, section 1 row, NOTICE. The description in the PPU spec
is the evidence and is unchanged; nothing is judged about how much was
taken.

Verified: provenance_record_audit 2/2; raw_signal unit tests 6/6;
markdownlint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…em 4)

v2.9.3 measured frame pacing in one configuration (Mailbox, 120 Hz,
run-ahead 0) and named the three it left out. This measures them: eight
45 s captures of the 29fc5a39 release frontend on flowing_palette.nes,
two per configuration, every one passing perf_log_check with
present_discarded=0.

- 60 Hz (display switched to 59.97 Hz with kscreen-doctor at the
  maintainer's approval, then restored): one present per vblank,
  p99 18.6-18.8 ms; Mailbox and Fifo are indistinguishable.
- 120 Hz Fifo: p95 9.3-9.6 ms, as even as v2.9.3's Mailbox (10.4-11.4),
  so the present mode is not what evens pacing on this host.
- Run-ahead 1: cost p95 doubles (3.1 -> 6.2 ms) and the worst produce
  interval grows 3-5 ms. Its present tail is NOT established: the two
  runs disagree (p95 11.92 vs 8.88 ms), and the record says so.

Recorded as an observation, not investigated: at 59.97 Hz the frontend
produced 59.9 fps, about 0.3% under NTSC, while every header reports
pacing_active = wallclock (checked in all eight).

Conditions stated with the numbers: one host, one ROM, KDE Wayland, and
the off-die co-simulation ladder holding one of 20 threads (load 1.6-2.0,
under v2.9.3's threshold of 2). The user config was backed up and
restored byte for byte (md5 checked).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
agy's v2.9.7 round-3 nitpick on #577: `insertCoin()` passed a bare `2u`
for the sub console. The bridge's `insert_coin` numbers acceptors 0/1 for
a cabinet's main (left) console and 2/3 for the sub (right) one, and a
single Vs. console has 0 and 1, so the button uses the first of each pair.
That is now MAIN_CONSOLE_COIN / SUB_CONSOLE_COIN in a private companion,
with the mapping in its doc comment. Behaviour is unchanged.

The other two round-3 suggestions are declined in the v2.9.8 plan with
reasons: `let _ = write!` is the workspace idiom (68 uses), and the
battery-refusal reason already reaches the player through emu.rs.

Verified: :app:testFossDebugUnitTest BUILD SUCCESSFUL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…fix categorize

The maintainer noticed the v2.9.6/v2.9.7 mapper work had no screenshots.
Three separate causes, each fixed or stated here.

1. The v2.9.7 real-game fixes got .snap hashes but no PNGs. Captured with
   the documented pipeline (scripts/coverage/README.md): external_coverage
   with RUSTYNES_DUMP_FRAMES=1, narrowed by RUSTYNES_COVERAGE_FILTER to 15
   ROMs. All six tests pass, so every hash matched; each PNG is the
   harness's `final` frame, the frame its .snap pins. Added:
   - six MC-ACC titles (mapper-004-3-MC-ACC);
   - StarTropics II (mapper-004-1-MMC6);
   - the DKC4 world map (mapper-211-JYCompany211);
   - the mapper-221 1000-in-1;
   - Time Diver Avenger (Unl).
   Re-captured, because the fixes or the v2.9.8 emphasis model changed them:
   - Time Diver - Avenger (Asia);
   - Felix the Cat;
   - Cobra Triangle;
   - mapper 112's Chik Bik Ji Jin, updated in place under its existing
     legacy name rather than duplicated.
   Names follow CURATION-MANIFEST.md (standardized, GoodNES flags dropped).
   Reviewed on a contact sheet: status bars and menus intact, the DKC4 map
   whole, Time Diver's playfield correct.

2. The tier trees were stale. `coverage.py categorize` (ADR 0011: the
   screenshot tree follows the mapper's tier) had not been re-run since
   about 90 families were promoted to Curated, so they sat under
   besteffort/. Re-run: 159 dirs under external/, besteffort/ empty but
   for its README. That is accurate: none of today's 31 BestEffort families
   has a committed capture.

3. Two defects in categorize itself, found running it:
   - Its tier.rs parser read only the FIRST match arm after each `// Tier:`
     marker. v2.9.6 added a second Curated arm, so GTROM (111) and the new
     families read as "unclassified". `_parse_tier_arms` now reads every
     `ids => Some(MapperTier::X)` arm with line comments stripped (their
     prose holds numbers like 268/286); it returns 51 / 109 / 31, matching
     tier.rs. The embedded fallback, which still listed 111 as BestEffort,
     is regenerated from it.
   - Merging a directory present in both trees moved file over file. That
     replaced six 2026-08-16 external/ captures (Wampus, five mapper-150
     Sachen titles) with 2026-06 duplicates from besteffort/. They are
     restored from HEAD, and categorize now keeps both copies of such a
     file and flags the pair. Checked on a scratch tree: the newer copy
     kept, the unique file moved, the duplicate flagged.

Not done, and why: the 17 v2.9.6 families still have no screenshots,
because no dump of them exists on this machine (T-MAPPER-DUMPS, open).
screenshots/README.md now states the count (448 PNGs / 159 dirs), that
most PNGs predate the current core (the .snap hashes are the baseline),
and the current regeneration route. besteffort/README.md says why the
directory is empty.

Verified: ruff check passes; markdownlint passes; external_coverage
6/6 on the 15 filtered ROMs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
… would collide

Staging the v2.9.6 families' real dumps (T-MAPPER-DUMPS, now possible
with the library reachable again) hit two gaps in coverage.py.

FAMILY had no board names for the 17 families, so `stage` would have made
`mapper-012-m12/`-style directories, and those names become the screenshot
folders and the external_coverage snapshot ids. Added from the board column
of docs/mappers.md (GouderSL5020B, SMB-Tetris-NWC, GA23C, SpikeVBall-NWC,
Waixing43-393, Cony-Yoko, JYCompany91 [matching 90/211], NES-EVENT,
KashengA9711, BandaiLZ93D50, NanjingFC001, Waixing191, WaixingFS308,
Waixing194, WaixingFS303, Action52, WaixingT9552). The diff is 17
additions and nothing else.

_base_title, which keeps staged titles distinct, kept punctuation, so
"Fatal Fury 2" and "Fatal Fury 2'" (a different dump) were both staged and
then shared the snapshot id mapper_083_Cony_Yoko_Fatal_Fury_2_Unl, which the
harness correctly refused (two different files claiming one baseline). It
now collapses non-alphanumeric runs the way the snapshot id does; both names
normalise to fatal_fury_2.

Verified: ruff check passes; discover now offers one Fatal Fury 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
NESdev INES_Mapper_153: "PPU $0000-$1FFF: 8 KiB unbanked CHR-RAM" and
"No CHR banking is available". The LZ93D50's PA12/PA13 inputs are grounded
on this board, so its $8000-$8003 registers drive the outer 256 KiB PRG
bank, and v2.9.6 correctly swallowed those writes (and $8004-$8007) instead
of storing them as CHR banks. But `chr_offset` still routed every pattern
fetch and CHR-RAM write through `chr_banks[slot]`, which on this board is
therefore all-zero forever: all eight 1 KiB windows resolved to bank 0,
the first 1 KiB of the 8 KiB RAM. Famicom Jump II's pattern tables
overwrote one another and its title screen rendered as vertical stripes.

`chr_offset` now returns `addr & $1FFF` for FcgVariant::Lz93d50Wram. The
other variants (mapper 16 submappers 0/4/5, mapper 159) keep the banked
path unchanged. The change is outside the file's Mesen2-derived EEPROM
region (the Provenance header is untouched).

Red first: m153_chr_ram_is_8k_with_no_aliasing_between_1k_windows writes
a distinct byte into each 1 KiB window (after writing $8000-$8007, which
must not reach CHR) and reads them back; it failed on the v2.9.6 code
("1 KiB window 0 aliased another window"), passes with the fix, and fails
again with the fix disabled (mutation caught). The existing
m153_chr_is_unbanked_ram test only touched address $0005, which aliasing
cannot break.

What this does NOT change: the dump still boots to a blank frame from a
fresh cartridge. Traced with debug-hooks access logging: with zero-filled
WRAM the game reads zeros from $6Bxx, bank-switches to bank 0 and returns
to $83F3, which is data; it BRKs into its halt loop at $C6F2. The NESdev
page documents exactly this ("When booting with WRAM filled with zeroes,
Famicom Jump II will freeze with a black screen. Simply soft-resetting
the console will then always run the game properly"). With WRAM pre-filled
$FF or pseudo-random, or after a soft reset, the title screen renders
correctly. Cartridge WRAM is zero at power-on core-wide by design; changing
that is a policy decision, not a board fix.

docs/mappers.md's 153 row and CHANGELOG [Unreleased] record both.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The MMC3-based boards in mmc3_boards.rs resolved every PRG and CHR-ROM
bank with `bank % banks`. That is correct only for power-of-two images.
The MMC3 drives its fixed last PRG bank as all ones ($FF); on a 24-bank
(192 KiB) image $FF % 24 = 15, and on a 20-bank (160 KiB) image
$FF % 20 = 15, so $E000 mapped a bank with no reset vector (bank 15 of
the Downtown translation holds RESET=$E000 over zero fill, bank 23 holds
the real $FF85). The 192 KiB and 160 KiB mapper 191 translations
(Downtown Nekketsu Monogatari, Downtown Special, Mighty Final Fight)
therefore booted to a one-colour frame.

Banks now go through `mirror_bank`, the inverse of the doubling
algorithm in nesdev_wiki Non_power_of_two_ROM_size: the image is grown
to the next power of two by repeatedly copying its last lowbit(size)
banks, so 192 KiB reads as ABCC and 160 KiB grows 20 -> 24 -> 32. The
function reduces modulo the grown size, then steps a bank lying in a
copied region back onto its source; it allocates nothing and loops at
most once per set bit of the bank count, so it is safe on the per-access
path of the no_std chip stack. For power-of-two images it is exactly
`bank % count`, so every power-of-two dump is unchanged. CHR-ROM (and
whole-image CHR-RAM) offsets use the same mapping via
`chr_rom_offset`, including `chr_phys`.

Red first: `non_power_of_two_prg_mirrors_by_the_doubling_algorithm`
(24- and 20-bank images: fixed banks land on 23/22 and 19/18, R6/R7
past the image fold per the page) and
`non_power_of_two_chr_mirrors_by_the_doubling_algorithm` (384 KiB CHR
on mapper 12) failed on the old code (15 vs 23; 0 vs 256) and pass now.
Restoring the modulo makes both fail again (mutation caught).

docs/mappers.md records the rule and three dumps from the same survey
that are not the board they are labelled as (Q Boy is Sachen 8259 /
mapper 141; Chaos World and San Guo Zhi 2 are Waixing FS005 / mapper
176 submapper 2; the Kasheng MK6 + Samurai Spirits 2-in-1 is mapper
291), with the register-write evidence, instead of forcing them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Nintendo World Championships 1990 (mapper 105, NES-EVENT) booted to a
one-colour frame forever. The mapper was not at fault. The cart's reset
code at $FF00 waits two vblanks (~57,190 CPU cycles, so the power-on
4-step frame counter has already set the frame IRQ flag at 29,828), then
runs STA $4017 (#$40), CLI, LDA #$FF. Its IRQ/BRK vector is $6010, in
WRAM the game has not filled yet. The frame counter deferred the
inhibit clear to the 3-4 cycle timer reset, so /IRQ was still high when
LDA #imm polled three cycles after the write; the CPU vectored to $6010,
read $00 (BRK), and re-entered the same vector for the rest of the run.
Traced with step_instruction: pushed P = $A0 (B clear, a hardware IRQ),
return address $FF1E, $4015 = $44 until cycle 57,196.

The NESdev "APU Frame Counter" page states the two effects separately:
bit 6 "If set, the frame interrupt flag is cleared", while only "the
timer is reset" after 3 or 4 CPU cycles. FrameCounter::write now clears
irq_flag, irq_line_active and any pending lazy $4015-read clear on the
write itself; the delayed reset path still clears again, harmlessly.
Whether a flag set inside the 29,828-29,830 window during a pending
reset should also be suppressed is not settled by any source and is
left unchanged (the test writes after the window, as the game does).

Red first: write_4017_inhibit_drops_the_irq_on_the_write_cycle failed
on the old code, passes with the fix, and fails again with the fix
reverted (mutation caught). The game now renders its "Welcome to
Nintendo World Championships 1990" title screen at 300 and 1,100
frames. No mapper code changed; the m105 row in docs/mappers.md is
unaffected.

Gates: cargo test --release --workspace --features test-roms
--no-fail-fast 3,069 passed / 0 failed / 20 ignored, including
AccuracyCoin, blargg apu 2005, apu_test, apu_reset, PAL APU and the
IRQ trace fixture; cargo test -p rustynes-apu / -p rustynes-mappers;
clippy -D warnings on both crates; cargo fmt --check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
T-MAPPER-DUMPS. v2.9.6 wrote 17 mapper families from their NESdev pages and
shipped without a library dump for any of them. The dumps were staged from the
maintainer's GoodNES set, booted through `external_coverage`, and each frame
was looked at before its baseline was accepted. This commits 44 `.snap`
baselines and the matching `final` frames as PNGs, the frame each `.snap` pins.

What was NOT committed, and why:

- Four dumps carry the wrong mapper in their header, recorded in
  docs/mappers.md by d0e89e3d rather than forced: Q Boy (mapper 141, not 191),
  Chaos World and San Guo Zhi 2 (176 submapper 2, not 74), and the Kasheng
  Super 2-in-1 (291, not 47). Their staged copies were moved out of the
  coverage tree to the gitignored salvaged/mislabelled-dumps/ (the originals
  remain in the GoodNES set), and their baselines were deleted. Each had booted
  blank, and a baseline of a blank frame on the wrong board pins nothing.
- Famicom Jump II (153) still boots blank: with zero-filled WRAM the game
  waits, as the NESdev page documents, and runs after a soft reset. Whether
  the harness should model a battery fill or press reset is the maintainer's
  decision.
- Famicom Yarou 54 (45) shows a blue screen: GA23C's register-2 power-on value
  is undocumented. Also the maintainer's decision.

Four baselines were captured AFTER this branch's fixes, and the blank
baselines accepted earlier in the session were replaced: Nintendo World
Championships 1990 (the $4017 fix, e092c1aa) reaches its title, and the three
mapper 191 Chinese translations (the non-power-of-two mirroring fix, d0e89e3d)
reach gameplay. Each was viewed before re-blessing.

`coverage.py categorize` put mappers 47, 121 and 191 in screenshots/besteffort/:
v2.9.6 tiered them BestEffort, so that README's "this directory is empty" note
was wrong and is corrected, as are the corpus counts (477 PNGs / 170 dirs in
external/, 10 in besteffort/).

Three committed PNGs were stale, recorded when the core rendered them as
garbage, and are replaced with today's frames: Uchuu Keibitai SDF (MMC5),
Mappy Kids (N163, fixed by v2.7.2's nametable hooks) and Cybernoid (Sunsoft-3).
Their `.snap` baselines already matched the current core, so only the pictures
were wrong. Pin Bot (Europe) is NOT among them: the current frame of that exact
dump is still garbled, and is being investigated as a live defect.

The screenshot READMEs named the harness crate `nes-test-harness`, renamed long
ago; they now say `rustynes-test-harness`.

Verification: a full external_coverage run with frame dumps over all 748
staged ROMs, a contact-sheet review of all 740 final frames, and the f900 /
f1100 / final frames for every suspicious one. After the moves above, the
targeted re-run of mappers 105 and 191 passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The two v2.9.6-family dumps left out of 4d2e29a1, settled by the maintainer
on 2026-10-01.

Famicom Jump II (mapper 153) joins KNOWN_BLANK, with a comment that sets it
apart from the other entries. It is not a defect: on zero-filled battery
WRAM the game waits for a reset (NESdev "INES Mapper 153"), and a soft reset
runs it. The harness powers every ROM on with zeroed cartridge RAM, so a
blank frame is the correct result. The rejected alternatives were a
harness-side soft reset for the board, and a non-zero WRAM power-on fill in
the core, which would change every battery board on no hardware evidence.

Famicom Yarou 54 (mapper 45) is ticketed as T-GA23C-POWERON, with a note in
docs/mappers.md's mapper 45 row. The maintainer chose "ticket and
KNOWN_BLANK", but the second half could not be done as asked: the blue frame
has enough colours to pass `frame_health`'s blank test, so a KNOWN_BLANK
entry would trip that list's own ratchet ("in KNOWN_BLANK but no longer
boots blank"). Its baseline therefore pins the blue frame, and the ticket and
the mappers.md row say so. The vendored NESdev page was re-read for this
commit: it says $6001 and a soft reset clear the outer registers, and gives
no value for them. The "register 2" detail in the ticket comes from an
earlier investigation and is labelled as such.

T-MAPPER-DUMPS in to-dos/ROADMAP.md now records the v2.9.8 outcome: 44
dumps pinned, three defects fixed red first, four mislabelled headers
recorded, and mappers 194 and 195 still without a dump.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Old ROM-management tools wrote signatures into iNES 1.0 header bytes
7-15, which the original iNES emulator ignored. The best known is
"DiskDude!": byte 7 becomes 'D' = $44, whose high nibble reads as mapper
bits 4-7 and adds 64 to the mapper number. `parse_header` trusted byte
7's high nibble unconditionally on the iNES 1.0 path, so any such image
loaded on the wrong board.

The NESdev "iNES" page gives the rule: "if the last 4 bytes are not all
zero, and the header is not marked for NES 2.0 format, an emulator
should either mask off the upper 4 bits of the mapper number or simply
refuse to load the ROM." `parse_header` now masks. The mapper assembly
moves into a `mapper_number` helper (parse_header crossed clippy's
100-line limit) that drops byte 7's high nibble when the header is not
NES 2.0 and any of bytes 12-15 is non-zero. A clean iNES 1.0 header has
zeros there, so a well-formed dump of mapper 16-255 is unaffected; NES
2.0 headers are exempt because bytes 12-15 carry real fields (region,
Vs. type, misc ROMs, expansion device). Garbage confined to bytes 8-11
does not trigger it, matching the page's wording.

Root cause of the v2.9.8 triage item: "Balloon Fight (J) [T+Rus_Mario
Soft]" is a 16 KiB / 8 KiB NROM image whose tail reads "@iskDude!"
(byte 7 = $40), so it loaded as mapper 64 (RAMBO-1) and the sky was
filled with a repeated, wrongly banked tile. It now loads as NROM and
plays with a black sky and clouds.

Corpus sweep (every iNES 1.0 image under tests/roms with a non-zero
byte 12-15):
- Balloon Fight [T+Rus] 64 -> 0: fixed.
- Asmik-kun Land [t1] and the Kyatto Ninden Teyandee hack, 244 -> 4
  (MMC3): were in KNOWN_BLANK, now reach their title / story screens,
  so they leave the ratchet list.
- Doraemon World 3 (Kiku hack), .nes and .zip, 72 -> 8 (FFE, not
  implemented): was blank as mapper 72, is now refused with
  UnsupportedMapper(8); moved from KNOWN_BLANK to UNSUPPORTED, its two
  coverage snapshots and its external_extended test + snapshot removed.
- Adventures in the Magic Kingdom, Tom Sawyer, Barbie (65 -> 1),
  Cybernoid, Karate Kid (67 -> 3), Matendouji (244 -> 4): the per-game
  database already rewrites their mapper to a value below 16 before the
  core parses, so their snapshots are unchanged (re-run, no diff).
- Fire Emblem (mapper 10) and flowing_palette (mapper 0) have a zero
  byte-7 high nibble: unchanged.

Caveat recorded in docs/cartridge-format.md: the database rewrites
bytes 6-7 but not the dirty tail, so a database mapper of 16 or more on
such a dump would be masked too; none in the local corpus is.

Verification:
- red first: header::tests::ines1_garbage_tail_masks_the_mapper_high_nibble
  failed (left 64, right 0) before the fix, passes after.
- mutations: disabling the mask -> FAILED (64 vs 0); dropping the
  !is_nes2 guard -> FAILED on the NES 2.0 case (0 vs 64).
- cargo test -p rustynes-mappers: 929 + 6 passed.
- external_coverage (filtered to every affected dump) and
  external_extended extended_m244: snapshots changed only for Balloon
  Fight [T+Rus], Asmik-kun Land [t1] (coverage + extended) and the
  Kyatto hack, each viewed after the change.
- cargo fmt --check, cargo clippy --workspace --all-targets -D warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The NESdev "RAMBO-1" page describes one clock of the IRQ counter as a
reload-or-decrement step followed by a single test: "If IRQ counter is
now 0 AND IRQs are enabled: trigger IRQ". `Rambo1::clock_irq` placed the
test inside the decrement branch only, so a reload that produced 0 --
either the reload forced by a `$C001` write or the reload of a counter
already at 0 -- never asserted. With a latch of 0 the documented counter
reloads 0 and raises the IRQ on every clock; ours stayed silent forever.

Skull & Crossbones (USA) (Unl) depends on exactly that. A register trace
of its first 400 frames (temporary instrumentation, not committed) shows
`$C000=00`, `$C001=00` (scanline mode), `$E001` written once per vblank,
393 times, and only two `$E000` writes: the game arms a zero-latch
scanline IRQ every frame and waits on its handler. The counter sat at 0
on every A12 clock and no IRQ ever fired, so the attract sequence showed
a black screen with a fragment of the "HINTS" page at f1100. With the
test moved after both branches the game runs its Tengen logo, its
title screen and the full hints page.

`clock_irq` now performs the reload or decrement and then returns
`counter == 0 && irq_enabled`. A non-zero latch behaves exactly as
before: a reload to a non-zero value cannot satisfy the test, and the
decrement-to-zero case is unchanged. The reload "+1 kick" (the page's
"ORed with 1") and the IRQ delay remain unmodelled, as the module
documentation already said; this change does not touch them.

Verification:
- red first: m064_rambo1::tests::zero_latch_reload_asserts_on_every_clock
  failed ("reload to 0 asserts") on the old clock_irq and passes now;
  that red run on the unmodified function is the mutation check.
- cargo test -p rustynes-mappers --lib m064: 10 passed (the existing
  scanline / cpu-cycle / ack tests unchanged).
- external_coverage filtered to mapper-064 (6 dumps): only the Skull &
  Crossbones snapshot changed; Hard Drivin', Road Runner, Shinobi,
  Xybots and Balloon Fight [T+Rus] (now NROM) are byte-identical.
- cargo fmt --check; cargo clippy -p rustynes-mappers --all-targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
NESdev "INES Mapper 019" documents $5800-$5FFF (read/write) as
`EHHH HHHH`: bit 7 is the IRQ enable (0: disabled, 1: enabled) and bits
6-0 are the high bits of the 15-bit up-counter. `Namco163` keeps the
enable in bit 15 of `irq_counter`, but its $5800 write handler masked
the value to 7 bits and then OR-ed in $8000 unconditionally, so every
write enabled the counter. The read handler masked bit 7 off, so the
enable never read back either.

Mechanism of the visible defect, in Digital Devil Story: Megami Tensei
II (Japan) (Rev A): the game drives raster bands from its IRQ handler
at $FE37 (bank 31). Each IRQ does INC $0348, and while $0348 < 10 loads
the next band's counter from a table into $5800/$5000 and the band's
four background CHR banks from $0349+X into $A000-$B800. After the last
band it takes the other branch, which writes $5800 = $00 and $5000 = $00
to stop the counter and restores $0349-$034C. With the enable forced
on, that "stop" restarted the counter from 0, so a stray IRQ arrived
every 32,768 CPU cycles (about 1.1 frames), incremented $0348 past the
band range for good and rewrote $A000-$B800 at an arbitrary scanline.
PPUCTRL was $90 (background at $1000), so the background was drawn
from whatever 1 KiB pages that stray write left, mostly page 0: the
repeated tile pattern behind the title logo and the intro text. Traced
with a temporary per-write log in the mapper and a CPU instruction
trace through `Nes::step_instruction` (neither committed).

The write now stores the whole byte in the high half, so bit 7 sets or
clears the enable; the read returns the high half including the enable.
The counter itself (counts while enabled, fires and stops at $7FFF,
$5000/$5800 writes acknowledge) is unchanged. The save-state layout is
unchanged: `irq_counter` was already a u16 carrying bit 15.

Verification:
- red first: m019_namco163::tests::namco163_5800_bit7_is_the_irq_enable
  failed ("a disabled counter never fires") before the fix, passes after.
- mutations: restoring the forced `| 0x8000` on write -> FAILED ("a
  disabled counter never fires"); restoring the `& 0x7F` on read ->
  FAILED ("bit 7 reads back the enable").
- cargo test -p rustynes-mappers --lib m019: 27 passed.
- external_coverage filtered to mapper-019 (6 dumps): only the Megami
  Tensei II snapshot changed; the title, the intro city skyline and the
  "CAUTION FOR DEVIL BUSTERS" text screen now render cleanly. Battle
  Fleet, both Famista, Final Lap and Mappy Kids are byte-identical, and
  external_real_games' four Namco 163 tests pass unchanged.
- cargo fmt --check; cargo clippy -p rustynes-mappers --all-targets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Root cause of two assigned live-render defects (Youkai Club, mapper 140,
solid blue screen; the Sachen Lightgun Game 2 in 1, mapper 150, scattered
garbage) and of eight more in the staged corpus: the boards were right,
the dumps were right, and the load path replaced the right mapper with a
wrong one before the core ever saw the header.

Mechanism. The frontend (`App::apply_game_db_header_overrides`) and the
coverage harness (`common::load_nes`, which mirrors it on purpose since
v2.3.4) look the ROM's header-excluded CRC32 up in the vendored per-game
table and write its region / mapper / submapper columns into the header
with `apply_header_overrides`. That table was compiled for iNES 1.0 images
and, for many boards, records a *compatible* mapper rather than the real
one (140 as 66, 150 as 243, 152 as 70, 159 as 16, 48 as 33, 241 as 178,
...). The substitute is harmless only when it decodes the registers the
game actually writes. Youkai Club writes its JF-11/14 bank register at
$6000 (traced: 0x23 / 0x13 / 0x33 / 0x03 every frame); mapper 66 decodes
$8000-$FFFF, so no bank ever switched and the game sat on a blue screen.
With its own NES 2.0 header (mapper 140) it boots, shows its title and
plays.

Evidence. A scratch scan of the staged corpus listed every dump whose
vendored row rewrites the header mapper or submapper: 41, of which 32 are
NES 2.0 images. Each of the 32 was run on the harness's input schedule
with and without the table: 20 identical, 2 differing only in a blinking
prompt (Auto-Upturn, Pocket Zaurus), and 10 rendering wrong or blank under
the table and correctly under their own header -- Youkai Club, Mississippi
Satsujin Jiken (140), Lightgun 2 in 1 (150), Gegege no Kitarou 2 and Saint
Seiya (152), Dragon Ball Z - Kyoushuu! Saiya Jin and both Magical
Taruruuto-kun (159), Bakushou!! Jinsei Gekijou 3 (48), Fan Kong Jing Ying
(241). None went the other way.

Fix. New `rustynes_gamedb::load_time_entry(crc, header)`: a user-overlay
entry is returned as written (a deliberate correction of this image); a
vendored row has its mapper and submapper dropped when the header is
NES 2.0. Region and mirroring are untouched (mirroring is applied
separately and only to hardwired boards). iNES 1.0 corrections, the
table's purpose (Seicross's mapper 185 submapper 4), still apply. Both
load paths call it: the frontend's chokepoint and the harness's mirror of
it, so the regression net keeps testing the program users run.

Tests. `vendored_mapper_never_overrides_a_nes2_header` (rustynes-gamedb)
uses the real vendored Youkai Club row (CRC 6BC65D7E, mapper 66) against
a NES 2.0 mapper-140 header and an iNES 1.0 one. It failed before the fix
(left Some(66), right None) and passes after; mutation (guard disabled
with `&& false`) makes it fail again: CAUGHT.

Snapshots re-blessed, each attributed to this change by the with/without
A/B above and by viewing the new frames: external_coverage for
Bakushou!! Jinsei Gekijou 3, Mississippi Satsujin Jiken, Youkai Club,
Lightgun 2 in 1, Gegege no Kitarou 2 (Japan), Saint Seiya, Dragon Ball Z
Kyoushuu, Magical Taruruuto-kun (Rev 1) and 2, Fan Kong Jing Ying. Five
leave KNOWN_BLANK (the ratchet failed until removed): Bakushou 3, the
three mapper-159 games and Fan Kong Jing Ying. The coverage sweep over
mapper dirs 036/048/070/079/087/118/140/148/150/152/159/206/241 passes.

Gates: cargo fmt --check; cargo clippy --workspace --all-targets -D
warnings; clippy on rustynes-test-harness with commercial-roms,test-roms;
cargo test -p rustynes-gamedb (13 + 2 pass); cargo test -p
rustynes-frontend (646 pass); markdownlint on the two docs.

Docs: docs/frontend.md (the per-game database paragraph claimed
"mirroring only", stale since v2.3.4; it now states the header
corrections and the NES 2.0 rule) and CHANGELOG [Unreleased] Fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Mapper 78 covers two boards whose bank register bit 3 is wired
differently: Holy Diver routes it through a 74HC00 mux to give H/V
mirroring, Cosmo Carrier connects it straight to CIRAM A10 for
single-screen A/B (NESdev `INES_Mapper_078`). NES 2.0 names them with
submappers 3 and 1. An image without a submapper -- every iNES 1.0 dump,
and NES 2.0 submapper 0 -- was always built as Holy Diver, ignoring its
header.

The page states the convention for that case: "iNES1 ROM image headers
often set the 'alternative nametables' flag for Holy Diver and cleared it
for Cosmo Carrier", and notes that Nestopia 1.4.0 and FCEUX 2.1.5 default
to Cosmo Carrier's 1scA/1scB wiring. The dispatcher now follows it: a
named submapper (1 or 3) wins in both directions; otherwise the
four-screen / alternative-nametables bit (flags 6 bit 3, which the header
parser reports as `Mirroring::FourScreen`) selects Holy Diver when set
and Cosmo Carrier when clear.

What it does not fix, by design. The assigned dump "Uchuusen - Cosmo
Carrier (J) [!]" is an iNES 1.0 image with flags 6 = $E8, i.e. the bit
SET -- byte for byte the same flags as "Holy Diver (J) [!]". Under the
documented convention its header names Holy Diver, so it still runs on
the H/V wiring and still stalls on a blue screen with one dot. That is a
mislabelled header, not a board defect: "Uchuusen Cosmo Carrier
(Japan).zip" carries the identical PRG+CHR (body SHA-1 prefix 23d302b901
in both) with a NES 2.0 header, submapper 1, and renders the game (the
planet, the MISSILE/BEAM/SHIELD menu). A scratch copy of the [!] image
re-headed to NES 2.0 submapper 1 renders the same. No CRC special case
is added (no per-game overrides); the vendored game DB does not help
either -- its row for this CRC carries submapper 0.

Corpus effect, measured on the coverage harness schedule: none. Holy
Diver (both dumps) and the [!] Cosmo Carrier set the bit (Holy Diver,
unchanged); the NES 2.0 Cosmo Carrier is submapper 1 (unchanged); the
Portopia DvD translation (iNES, bit clear) moves to the Cosmo Carrier
wiring and its f300/f900/final frames are byte-identical either way
(md5 compared on scratch re-headed copies), so no snapshot changed. The
mapper-078 coverage sweep passes unmodified.

Test: `mapper_78_ines_header_selects_wiring_by_alt_nametables_bit`
(rustynes-mappers, lib.rs) parses synthetic mapper-78 images and checks
the mirroring after a bit-3 write: iNES clear -> 1scB, iNES set ->
Vertical, NES 2.0 sub 0 clear -> 1scB, sub 1 with the bit set -> 1scB,
sub 3 with it clear -> Vertical. Red before the fix (iNES clear gave
Vertical). Mutations: fallback arm back to Holy Diver -> CAUGHT; the
four-screen arm disabled -> CAUGHT ("iNES 1.0, flag set").
`v21_coverage_mappers::mapper_78_holy_diver_default_boots` is renamed
`mapper_78_no_submapper_default_boots`, since its synthetic image now
boots on the Cosmo Carrier wiring; it still passes.

Gates: cargo fmt --check; cargo clippy --workspace --all-targets -D
warnings; clippy on rustynes-test-harness with commercial-roms,test-roms;
cargo test -p rustynes-mappers (929 lib + integration pass);
v21_coverage_mappers mapper_78 (2 pass); markdownlint.

Docs: docs/mappers.md row 78, the module preamble, CHANGELOG
[Unreleased] Fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The v2.9.8 live-render survey assigned five more blank or garbled boots.
Two were fixed in the preceding commits (the game DB rewriting NES 2.0
headers; mapper 78's iNES fallback). The rest are not board defects, and
are recorded here instead of forced, since no per-game override is added.

21-in-1 [p1][!] (header: 133, Sachen SA-72008). The 72008 is a $4100
latch with one PRG bit and two CHR bits (NESdev `INES_Mapper_133`), so it
addresses 64 KiB PRG / 32 KiB CHR; the image has 128 KiB / 64 KiB. Traced
with the event log, the game never writes $4100; its first mapper-space
writes are $8001=$80, $F000=$80, $F020=$A2, $F001=$00 -- an
address-encoded latch. Re-headed in scratch to each implemented
address-latch multicart (57, 58, 62, 200-203, 212-214, 225-227, 229, 231,
242, ...), only 225 (ET-4310 / K-1010, `INES_Mapper_225`: banks, PRG mode
and mirroring in the written address) boots to the "21 GAME" menu and
holds it at f900; as 133 it is tile noise from the first frame. Verdict:
mislabelled, the board is mapper 225.

Zhan Guo Si Chuan Sheng (C&E) (Unl) (header: 132, TXC 22211). NESdev
`INES_Mapper_132` names this image in its Notes: GoodNES sets it to 132,
but it is a mapper hack with CHR rearranged for some emulators' mapper
132, which "only works on certain emulators' implementation of Mapper
132, not on the above implementation based on studying the circuit
board"; the correct board is 173. Observed: the title and the map /
player-select screen render correctly at f300, and in-game frames (after
the harness's START taps start and pause a game) are black with one bar
where the PAUSE text is drawn from the wrong CHR bank. Verdict:
mislabelled; mapper 173 is not implemented, so no re-header was
possible to confirm the rest.

Uchuusen - Cosmo Carrier (J) [!]: cross-reference to the mapper-78 row
(its header's alternative-nametables bit names Holy Diver).

BB Car (Asia) (En) (Unl) (header: 152; the vendored game DB says 7):
not a defect. The event log shows no mapper write at all in 120 frames,
so the board does not matter; the "0123456789" screen with one sprite is
the game's own. Its controller loop (disassembled at $9140: strobe $4016,
read A and test it, four bare reads that discard B/Select/Start/Up, then
tested D-pad reads) ignores START, which is all the harness taps. Tapping
A on the same schedule reaches the race (track, cars, RN 07, score) by
f900. The harness capture is what lands on the start screen; a capture
override is the lead's call.

Docs only. markdownlint passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Magic Floor (iNES mapper 218) has no CHR chip. The console's 2 KiB CIRAM
is both the pattern table and the nametable, and the board ties CIRAM A10
directly to one PPU address line chosen by the iNES flags-6 bits
(NESdev "INES Mapper 218"):

  $A1  CIRAM A10 = PPU A10   2 KiB CHR shared with two nametables
  $A0  CIRAM A10 = PPU A11   2 KiB CHR shared with two nametables
  $A8  CIRAM A10 = PPU A12   1 KiB per pattern table, one screen (bank 0)
  $A9  CIRAM A10 = PPU A13   1 KiB CHR (bank 0), one screen (bank 1)

Root cause, two defects stacked:

1. `header::parse` folds bit 0 into `Mirroring::FourScreen` once bit 3 is
   set, and `MagicFloor218::new` mapped `FourScreen` to the A10 wiring. So
   both single-screen headers ran as A10. Both Magic Floor dumps in the
   local corpus carry flags 6 = $A9, so the game's nametable writes at
   $2000-$23FF landed in CIRAM bank 0, the same memory as its tiles at
   $0000-$03FF, and the bottom of the screen (and the tile shapes) filled
   with garbage.

2. Even when reached, the single-screen modes resolved to a constant bank
   for BOTH pattern and nametable space (ScreenA -> 0, ScreenB -> 1). The
   documented wiring makes the bank a function of the address line: under
   A12, pattern table 1 is bank 1 while the nametables are bank 0; under
   A13, all pattern space is bank 0 and the nametables bank 1.

Fix: `MagicFloorMode::physical_bank` now takes the full PPU address and
returns bit 10, 11, 12 or 13 of it, the literal wire, for both the CHR
and nametable paths (the old 1 KiB block-index arithmetic for the two
two-screen modes is the same function of A10 / A11, so those are
unchanged). `parse` re-reads raw `bytes[6]` bit 0 when the four-screen bit
is set, exactly as mapper 30 already does, and passes SingleScreenB ($A9,
A13) or SingleScreenA ($A8, A12) to the existing constructor. No public
signature changes. A caller that passes a bare `FourScreen` still gets
the A10 wiring as before.

Evidence and verification:
- Red first: `m218_single_screen_a_wires_ciram_a10_to_ppu_a12`,
  `m218_single_screen_b_wires_ciram_a10_to_ppu_a13` (lib) and the new
  `tests/magic_floor_218.rs` (parse-level, all four header values) failed
  before the fix (2 of 3 parse tests, 2 of 5 m218 unit tests).
- Mutations: dropping the raw-byte dispatch in `parse` fails 2 of the
  parse tests; returning the old constant banks for the single-screen
  modes fails both new unit tests.
- Frames: both dumps now draw the board and "MAGIC FLOOR 0 OF 66 / 34
  POINTS" score line at f900, f1100 and final (they drew garbage tiles).
- Snapshots re-blessed, attributable to this change only:
  external_coverage__mapper_218_MagicFloor_..._PC10_Version_PD.snap and
  ..._PD_a1.snap. No other mapper uses this board.

Provenance: the replaced function's doc said "Matches `GeraNES`
`customMirroring`", a source cross-check of the kind
docs/originality-and-provenance.md records under "GeraNES specifically".
The rewrite dropped that line. It has been restored at the site as a dated
note (provenance mentions are classified, never deleted; maintainer rule of
2026-09-22). The new code was written from the NESdev page alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
NESdev "INES Mapper 226" (76-in-1 / Super 42-in-1 BMC) documents two
registers decoded with mask $8001:

  $8000  [PMOP PPPP]  bits 4-0 = PRG bits 4-0, bit 5 (O) = PRG mode
                      (0 = one 32 KiB bank, 1 = the same 16 KiB bank in
                      both halves), bit 6 (M) = mirroring (0 = H, 1 = V),
                      bit 7 (P) = PRG bit 5
  $8001  [.... ...H]  bit 0 = PRG bit 6

Root cause: `Multicart226` took PRG bits 5-0 from `reg0 & 0x3F` (so the
mode bit O became PRG bit 5), the mode from bit 6 (M), and mirroring from
bit 7 (P). The docs row quoted the right `[PMOP PPPP]` string but annotated
the wrong bits, and the old unit test encoded the same misreading. On the
2 MiB 76-in-1 the menu's first bank switch landed in the wrong bank, and
the screen stayed a field of one repeated tile at every capture point.
Super 42-in-1 happened to boot, but under bit-7 mirroring its menu scroll
showed the second page (games 12-22) with the header off-screen.

Fix: PRG = (reg1.0 << 6) | (reg0.7 << 5) | (reg0 & 0x1F); 16 KiB mode =
reg0 bit 5; vertical mirroring = reg0 bit 6. The page also says "The
multicart clears both registers on soft reset", so the board now
implements `Mapper::reset` (registers to 0: bank 0, 32 KiB mode,
horizontal), and the trait's doc list of boards that clear on reset names
it. Power-on is unchanged (the constructor already zeroes both).

Evidence and verification:
- Red first: `m226_reg0_layout_is_pmop_pppp` failed before the fix (left
  34, right 35). The old `m226_two_regs_select_prg_and_mirror` encoded the
  misreading ($43 as "16K, H"), and is rewritten to the page's layout
  ($23 = 16K, H, bank 3).
- Mutations, each reverted after: PRG bit 5 from reg0 bit 5 -> FAILS
  (after strengthening the test with $23 and $83: the first draft used only
  $A3, where bits 5 and 7 are both set, and that mutant SURVIVED); mode
  from bit 6 -> FAILS (both tests); mirroring from bit 7 -> FAILS; reset
  that keeps reg1 -> FAILS.
- Frames: 76-in-1 now shows "76 IN 1 GAME / PAGE.1-8 / 1990 TSANG HAI";
  Super 42-in-1 now shows "SUPER 42 IN 1 / 22 GAMES / PUSH 'SEL' TO NEXT"
  with games 1-10, at f900, f1100 and final.
- Snapshots re-blessed, attributable to this change only (the only two
  mapper-226 dumps):
  external_coverage__mapper_226_BMC_76in1_76_in_1_p1.snap,
  external_coverage__mapper_226_BMC_76in1_Super_42_in_1_22_Games_p1.snap.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The Vs. System CPU board (MDS-0x, the same board in both cabinet types)
carries 2 KiB of RAM at CPU $6000-$7FFF. NESdev "Vs. System" ($4016
write, bit 1) documents its arbitration on the primary CPU: "When 1: the
primary CPU has access and the secondary CPU sees open bus. When 0: the
secondary CPU has access and the primary CPU sees open bus."
INES_Mapper_099 lists "CPU $6000-$7FFF: 2 KiB RAM, swappable between CPUs
(open bus when not available)".

Root cause: `VsSystem` provisioned that RAM only through the DualSystem
wrapper (`enable_vs_dual_wram`). On a UniSystem cart `$6000-$7FFF` read 0
and every write was dropped. Vs. Super Mario Bros. clears $6600-$67FF,
copies CHR data into $6674-$67FF with OUT1 high, and later reads its top
score and state back from $66BA..; with the RAM missing it parks in its
NMI loop with rendering effectively blank. Every $4016 value the game
writes (2, 3, 6) keeps OUT1 set.

Fix: a UniSystem `VsSystem` owns a 2 KiB `uni_wram` and an `out1` latch
taken from bit 1 of every forwarded $4016 write. While `out1` is high the
window reads and writes the RAM (mirrored every 2 KiB); while low,
`cpu_read_unmapped` reports open bus and writes are dropped. The
DualSystem path is unchanged (it keeps its shared, ungated copy; modelling
OUT1 arbitration there is out of scope and would touch the four dual
titles). Save state: UniSystem now emits layout v3 = v1 + OUT1 byte +
2 KiB RAM; v1 still loads (RAM cleared, OUT1 low, the power-on state);
the dual v2 layout is untouched.

Evidence and verification:
- Red first: `unisystem_ram_follows_4016_bit1` failed before the fix
  (the window stayed unmapped with OUT1 high).
- Mutations, each reverted: OUT1 taken from bit 2 -> FAILS; writes that
  ignore OUT1 -> FAILS; save state that drops the RAM -> FAILS.
- Game: the corpus's `VS Super Mario Bros.nes` is headed mapper 3 with
  no Vs. flag (flags 6/7 = $31/$00), so it runs as CNROM and its coverage
  frame does not change. Its code writes $4016 37 times (CHR bank via bit
  2) and the $4020 coin counter, and never writes a CNROM register, so its
  board is mapper 99. Run through a scratch probe with only header bytes
  6/7 rewritten to $31/$61 (mapper 99, Vs.), PPU RP2C04-0004 and DIP $10
  (the vs_db values for the title): before this change, a green backdrop
  with the CPU in its NMI loop; after, the SMB attract demo in correct
  colours. Recorded in docs/mappers.md, not forced.
- No snapshot changed: the coverage suite over `vs-system`, `mapper-099`
  and `mapper-151` (every Vs. dump in the corpus, including the four
  DualSystem titles) matches its committed baselines.
- `cargo test -p rustynes-mappers` and `-p rustynes-core` green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
NESdev "Vs. System" (Palette): "To determine which PPU is used, read the
PPU type byte of the NES 2.0 header if available; otherwise, use the hash
of the PRG and CHR ROM data." `vs_db` is that hash table. It carried a row
for the patched `VS The Goonies Hack.nes` (RP2C04-0003, DSW0 $80) but none
for the unpatched Konami dump `Goonies, The (VS).nes` (SHA-256 ff268fb3...),
so the latter fell back to the parser's 2C03 default and drew its "VS."
title red on a green backdrop. The two files differ only in 327 PRG bytes
of code (six ranges at $80ec, $85aa, $f083, $f0bb, $f570-$f6ad); header,
board and CHR are identical, so the PPU is the same chip. The new row
carries the hack row's PPU and DIP default over; no new external source
was consulted.

This is palette selection by hash, the documented mechanism, not a boot
override: the game already ran (it reached its ROM check and attract
screen); only its colours were wrong.

Records, no behaviour change:
- docs/mappers.md (mapper 151 row), m151_konami_vs.rs and lib.rs no
  longer cite "GVS VS. TKO Boxing" as a mapper-151 example. Both corpus
  T.K.O. Boxing dumps (byte-identical, a6332035...) carry a mapper-151
  header, but their reset handler writes $8000=6, $8001=8, $8000=7,
  $8001=9 (Namco 108 bank select / data) and nothing writes $9000-$F000;
  VRC1 decodes every $8000-$8FFF write as the $8000 PRG bank, so the boot
  goes nowhere and the frame stays grey. NESdev "Vs. System" names the
  Namco 108 (mapper 206) plus an extra protection IC for three third-party
  Vs. games; NES 2.0 encodes T.K.O. Boxing's as Vs. hardware type 2, which
  RustyNES does not model. Recorded as mis-labelled, not forced.

Verification:
- Red first: `goonies_original_dump_uses_the_2c04_0003_palette` failed
  before the row (lookup returned None). Mutation: the row with
  `Rp2C03` -> FAILS. `db_is_sorted_by_sha256` and
  `every_entry_is_findable` pass (the row sorts last, after fda84d8d...).
- Frames: f900 shows the white-on-black "GOONIES / ROM 1 OK / ROM 2 OK"
  check; final shows the orange "VS." title, "HI 10000", "(c) KONAMI 1986"
  and "CREDIT 01" on black (was red on green). A 2000-frame probe shows the
  white-on-black Warner Bros. licence screen.
- Snapshot re-blessed, attributable to this row only:
  external_coverage__mapper_151_KonamiVS_Goonies_The_VS_zip.snap. The other
  mapper-151 snapshots (Gradius, both T.K.O. Boxing, Silent Assault) are
  unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Two blank or garbled boots from the v2.9.8 commercial survey are not
board defects; this records why, so they are not re-investigated or
"fixed" by a per-game override.

Mapper 212, 999-in-1 [p1]: the menu text is right but every unused cell
shows the font's `0`. A nametable probe after 600 frames shows the menu
wrote only its text tiles; the rest of $2000-$23BF is tile $00. The reset
handler at $EF60 waits for one vblank ($FFA1: LDA $2002 / BPL), then
calls $F056, which sets PPUADDR to $2000 and writes 960 x $24 plus 64
attribute zeros. That $2006 write lands about 27,400 CPU cycles after
power-on, inside the NTSC warm-up in which PPUCTRL, PPUMASK, PPUSCROLL and
PPUADDR writes are ignored (~29,658 cycles; NESdev "PPU power up state",
modelled by `PpuRegion::post_reset_mask_cycles`). The fill therefore goes
to pattern space at v = 0. The same page notes that a Famicom's PPU leaves
reset about a frame before the CPU, which such carts rely on. Scratch
experiment (reverted, not committed): with the NTSC mask set to 0 the menu
draws on a clean background. Whether to model a Famicom power-on is a
console-model decision for the maintainer; the board matches
INES_Mapper_212 (address latch, $6000 D7 = !A4).

Mapper 233, Unknown Multi Cart w-Galaxian [p1]: INES_Mapper_233 says this
dump ("Unknown Multicart 1") "does *not* follow the description in this
doc at all": 32 NROM-128 games with no menu, CHR page = PRG page - 8,
per-game mirroring, and "might even be assigned the wrong mapper number".
No documented board fits, so none is guessed.

Docs only; no code or snapshot changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ts board

The artifact audit's fixes landed as 11 commits from three worktree agents,
cherry-picked onto this branch. This commit closes the records around them.

Screenshots. Every external_coverage baseline that those commits changed was
viewed on the integrated tree: 20 frames, each now a real title, menu or
play screen. Their final frames replace 10 committed PNGs (Bakushou 3,
Balloon Fight [T], Lightgun 2-in-1, two Bandai 24C01 titles, both Magic
Floor dumps, both mapper 226 multicarts, Kyatto Ninden) and add 11 for
games the corpus had no picture of. For mapper 226 the fixed frames
overwrite the existing file names rather than adding a renamed pair, so the
corpus does not carry the garbled and fixed captures side by side. Corpus:
488 PNGs in 171 directories under external/, 10 in besteffort/.

Famicom Jump II. Its first baseline (0a0e8e56) was taken while the vendored
game database rewrote this dump to mapper 16. The database guard (9bc58595)
stops that, so the full sweep re-ran it as 153. It is still blank, as its
NESdev page documents, but the frame hash moved. A mismatched snapshot is
reported as a failure before the blank test runs, so KNOWN_BLANK's ratchet
also said the game "no longer boots blank", which was wrong. The baseline
is re-taken on the correct board and the entry stays.

KNOWN_BLANK's doc comment now gives the v2.9.8 count (60 -> 52: seven
entries now render, the two Doraemon hacks moved to UNSUPPORTED as the
mapper 8 images they are, and Famicom Jump II was added). The plan's
outcome row lists every fix, mislabelled dump and non-defect.

Verification: a full external_coverage sweep on the integrated tree, 744
ROMs, failed only on Famicom Jump II, for the reason above; after the
re-take, that ROM passes. The test-roms suite will be re-run before the
cut.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Maintainer decision 2026-10-01, "Identity default": the eight 1 KiB CHR
bank registers of the Konami VRC6 (mapper 24 = VRC6a, mapper 26 = VRC6b;
$D000-$D003 and $E000-$E003) now hold 0, 1, ..., 7 when the board is
constructed, so slot i ($0000 + i * $400) maps CHR bank i until the
program writes the register. Previously all eight started at 0, so every
1 KiB window showed the first 1 KiB of CHR.

This is an ASSUMPTION and is recorded as one. The NESdev VRC6 page
(fetched 2026-10-01) states no power-on or reset value for any VRC6
register; the old all-zero state was equally undocumented. The new value
is held in one named constant, `POWER_ON_CHR`, whose rustdoc carries the
reasoning and the evidence.

Evidence: "Pulsewave Invite (2009-08-xx) (PD)" never writes $D000-$E003
and expects the first 8 KiB of CHR-ROM mapped in order. Under the old
layout its boot frames were a scatter of misplaced tiles; under the
identity layout it draws its postcard ("This weekend in NYC we celebrate
the end of summer at Pulsewave!", the beach scene below). Its .snap
changes only in the f900/f1100 framebuffer hashes; cycles, audio sample
count and audio hash are unchanged, as expected for a CHR-only change.

Scope decisions:

- Power-on only. The value is applied in `Vrc6::new`, which is also what
  a power cycle runs (the core rebuilds the mapper). `Mapper::reset`
  stays the default no-op: NESdev documents no reset input to the VRC6,
  and a console soft reset leaves cartridge register latches alone on
  almost every board (the trait's own documentation lists the few that
  clear something). A game reset mid-play therefore keeps its banks.
- PRG unchanged. $8000 (16 KiB) and $C000 (8 KiB) still power on as 0.
  The decision covered CHR only, the reset vector lives in the fixed
  $E000 bank, and no documentation or dump gives evidence for another
  PRG value.
- Save states unaffected: the CHR registers were already serialised, so
  a loaded state overrides the power-on value exactly as before.
- CHR-RAM VRC6 boards (none in the corpus) now see the 8 KiB RAM mapped
  in order before the first register write, rather than eight aliases
  of its first 1 KiB.

Tests (red first, in crates/rustynes-mappers/src/m024_vrc6.rs):

- vrc6_chr_registers_power_on_as_identity: for mappers 24 and 26, each
  slot reads its bank number from a synthetic 32 KiB CHR-ROM whose
  1 KiB banks are tagged. FAILED before the fix.
- vrc6_soft_reset_keeps_chr_registers: a written register and an
  untouched slot both survive `reset()`. FAILED before the fix (the
  untouched slot read bank 0).

Mutation checks:

- revert the initialiser to [0; 8]: both tests FAIL (caught).
- add a `reset()` that reloads POWER_ON_CHR: the soft-reset test FAILS
  (caught).

Frame verification: every staged mapper 24/26 dump (11: Akumajou
Densetsu VC, the Castlevania III retranslation, Pulsewave Invite,
Rudeboy, Sun Dried Demo, SMB co-op hack, Esper Dream 2 x2, Madara x2,
Go-Go! Ice Challenge Ultimate!) was dumped with RUSTYNES_DUMP_FRAMES=1
before and after; `cmp` on all 33 PNGs: 30 byte-identical, the 3
differing are Pulsewave's f900/f1100/final. Only Pulsewave's snapshot
was re-blessed; the filtered coverage run then passes all 11.

Gates: cargo fmt --check; cargo clippy --workspace --all-targets
-D warnings; cargo test -p rustynes-mappers (938 lib tests + the
integration suites, 0 failed); AccuracyCoin 144/144 (RAM, 0 fail);
nestest golden log 0-diff; markdownlint on the two docs.

Docs: docs/mappers.md rows 24 and 26 note the assumption, the Pulsewave
evidence, the power-on-only scope and the untouched PRG. CHANGELOG
[Unreleased] lists it under Changed, not Fixed, because it swaps one
undocumented power-on value for another rather than correcting
documented hardware behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Maintainer decision 2026-10-01: an off-by-default Famicom console option
whose PPU leaves reset earlier than the NES's, with the default build
unchanged.

What the hardware does (NESdev "PPU power up state", vendored
nesdev_wiki/output/PPU_power_up_state.md, sections "Famicom" and the
front-/top-loader note):

- NES-001: the CPU and PPU share one reset line. After power-on and after
  every Reset, writes to $2000/$2001/$2005/$2006 are ignored for about
  29,658 CPU cycles (NTSC; 33,132 PAL), and Reset clears PPUCTRL, PPUMASK,
  the scroll/address latch and the read buffer.
- Famicom: the PPU's /RESET is tied to 5 V and only the CPU's rides a
  0.47 uF capacitor, so at power-on the PPU starts initialising
  "approximately one frame before the CPU reset" (not measured, may vary).
  One NTSC frame (~29,781 cycles) is longer than the 29,658-cycle window,
  so the window has closed by the CPU's first instruction. The Reset
  button resets only the CPU.

The 999-in-1 multicart (mapper 212) clears its nametable through
$2006/$2007 at about CPU cycle 27,400, inside the NES window. Both $2006
writes are dropped, the fill goes elsewhere, and the menu is drawn over a
nametable still holding tile $00 (the font's "0").

Mechanism:

- rustynes-ppu: Ppu::end_warmup() zeroes post_reset_mask_remaining, and
  Ppu::warmup_cycles_remaining() reads it. New code, written from the wiki
  text; it sits outside every region ppu.rs's Provenance header discloses
  (sprite evaluation, OAM-data bus, OAM decay, ALE/octal latch, OAM
  corruption), and nothing was copied from those regions.
- rustynes-core: ConsoleModel { Nes (default), Famicom }, re-exported at
  the crate root. LockstepBus stores it as config (like ppu_die_revision):
  power_cycle() ends the rebuilt PPU's warm-up under Famicom, reset() skips
  Ppu::reset() under Famicom (APU, DMA and mapper reset as before), and
  set_console_model(Famicom) ends a window in progress, which is how a
  host applying its config straight after construction gets the Famicom
  power-on. Selecting Nes never re-arms a closed window. Nes exposes
  set_console_model / console_model.
- Not modelled, and documented: the PPU's frame position at power-on is
  not advanced by a guessed "approximately one frame"; the NES-101
  top-loader is not a separate model (its power-on lead is undocumented).
  No per-game list and no header heuristic select it; NES 2.0 has no
  console type that tells a Famicom from an NES.
- Save states: no format change. The model is config; the counter it acts
  on is already in the PPU section. snapshot_schema_audit lists the new bus
  field as config.
- Determinism: deterministic, but movies and netplay do not record it,
  the same as the other hardware knobs (OAM decay, die revision, power-on
  RAM). Recorded as a gap in docs/ppu-2c02.md, not closed here.
- Frontend: [emulation] famicom_console (default false), a checkbox under
  Settings > Emulation > Accuracy with EN/ES strings, and
  App::apply_console_model run on ROM load, at startup and on a Settings
  change via its own SettingsApply::console_model flag. It is kept off the
  fast_dotloop path because apply_ppu_hardware_config also re-runs
  set_power_on_ram, which rewrites work RAM. "Reset to defaults" pushes it.
  Power-cycle keeps the model inside the core.

Tests (red first):

- nes::tests::famicom_console_model_ppu_leaves_reset_before_the_cpu: a
  ROM writes $2000=$90 and $2001=$01 once, as its first instructions. NES
  model: both dropped. Famicom: both land; Reset keeps them and re-arms
  nothing; NES Reset clears them and re-arms the window; the model
  survives a power cycle. Failed against a store-only setter (warm-up
  29,650 remaining).
- ppu::tests::end_warmup_lets_masked_registers_write_immediately.
- Mutations, each caught: setter without the power-on apply (nes.rs
  assertion at the Famicom boot), unconditional ppu.reset() (Famicom Reset
  assertion), power_cycle without the apply (power-cycle assertion),
  end_warmup as a no-op (both tests).
- tests/famicom_console.rs (commercial-roms, local dump): 999-in-1 after
  300 frames. Tile $00 in the first nametable: NES 388/960, Famicom 2/960;
  the Famicom frame shows the menu on a clean background.
- config: famicom_console defaults off and round-trips; reset_advanced
  pushes the flag.

Default unchanged: AccuracyCoin 144/144, nestest 0-diff; external_coverage
untouched (no re-bless) for mapper-212 (11 ROMs), mapper-000 (12),
mapper-001 (21), mapper-004 (33), mapper-019 (6). fmt, workspace clippy,
frontend clippy (scripting, scripting+hd-pack, retroachievements), both
wasm clippy gates, harness commercial-roms clippy, no_std thumbv7em build
and rustdoc -D warnings pass.

Docs: docs/ppu-2c02.md (Famicom console model section), docs/frontend.md
(Emulation tab), CHANGELOG [Unreleased] Added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
Found by the agent that added the Famicom console model (1d128692), while it
was deciding which apply path its own setting should take.

Mechanism. The Settings window's live-apply for `fast_dotloop` called
`apply_ppu_hardware_config`. That function pushes the whole `[emulation]`
PPU knob set: the PPU revision, the power-up palette, the power-on RAM fill,
and the fast-dot-path selector. Its comment called the re-push "idempotent".
It is not: `Nes::set_power_on_ram` -> `LockstepBus::set_power_on_ram`
immediately calls `apply_power_on_ram`, which fills the 2 KiB work RAM (and
the open-bus latch) with the power-on pattern, zeros by default. So flipping
a switch documented as frame-identical wiped the running game's RAM mid-play.
The three power-on knobs are only meaningful at load, power-cycle and
startup, which are the other three callers, and those stay as they were.

Fix. A new `apply_fast_dotloop` pushes the selector alone, and the live
branch calls it. The Famicom option already had its own path for the same
reason, and its doc comment said so.

Test. `live_settings_never_rerun_the_power_on_ram_fill` is a source-shape
gate, the same style as the wasm entry-point tests in this module, because
`App` needs a window and an emulator thread. It cuts its own test module out
of the source before searching (a file that reads itself must exclude the
part doing the reading; this module's first such test learnt that in
review). It asserts three things:
- the fast-dot-path branch calls `apply_fast_dotloop`;
- no `if settings.<knob> { ... }` live-apply branch reaches
  `apply_ppu_hardware_config`;
- the helper never calls `set_power_on_ram`.
Mutation: putting the old call back in the branch makes it fail; with the
fix it passes.

Not done here: the same agent noted that on ROM load the frontend sets
`has_rom` before running the `apply_*` hooks, so the emulator thread could
run a frame before the power-on options apply. That needs a measurement
first, and is left as a follow-up.

Also in this commit: the Pulsewave Invite screenshot for 8a0ee92c (the VRC6
power-on banks), its final frame, the postcard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…2.0's own

Maintainer decision (2026-10-01): "Honour DB region now" -- apply the game
database's region for iNES 1.0 headers in v2.9.8, audit the affected dumps'
frames, and correct any rows that are wrong.

Root cause
----------
`apply_header_overrides` wrote a PAL row into iNES 1.0 header byte 9 bit 0,
the TV-system flag. `parse_header` ignores that bit by design and parses every
iNES 1.0 image as NTSC, because raw dumps carry junk there ("DiskDude!" puts
's' = $73 in byte 9; on the staged corpus three pirate multicart images the
table does not list -- mapper 60's 4-in-1, 133's 21-in-1, 200's 7-in-1 -- have
$01 there in an otherwise clean header with nothing to confirm it). So no PAL
row reached any iNES 1.0 game, in the frontend or in the coverage harness.
Pin Bot (Europe), TQROM, uploads CHR-RAM for the length of the PAL vblank; at
NTSC the upload overran into rendering and garbled the title. Conversely, for
NES 2.0 headers the row DID rewrite byte 12, overriding a region the header
states.

Design
------
The region travels in the header bytes, through the existing chokepoint
(`load_time_entry` -> `apply_header_overrides`), because every load path
already runs it (desktop File menu and CLI, browser, coverage harness) and
everything that rebuilds later re-parses the corrected bytes (power cycle).
A constructor parameter would have had to be threaded through each of those
sites, which is how earlier corrections were lost three times. `parse_header`
is unchanged: raw byte 9 is still ignored.

* `load_time_entry`: a vendored row's region is withheld for a NES 2.0
  header, alongside its mapper and submapper. An iNES 1.0 header takes it.
* `apply_header_overrides`, iNES 1.0 + PAL/Dendy: `ines1_as_nes2` builds the
  NES 2.0 header of the SAME board -- the mapper the iNES 1.0 parse settled on
  (after the dirty-tail rule) written cleanly, submapper 0, byte 9 = 0 (plain
  byte-4/5 sizes), region in byte 12, bytes 13-15 zero -- and tries two RAM
  statements in order: the iNES 1.0 heuristics written out (8 KiB PRG-RAM,
  in the NVRAM nibble when byte 6 has the battery bit; 8 KiB CHR-RAM iff no
  CHR-ROM), then no RAM at all. Each candidate is parsed and compared with the
  iNES 1.0 parse by `same_board`: cartridge identity, mirroring, console type,
  Vs. fields, battery, trainer, PRG/CHR, and the mapper's capability flags,
  mirroring answers, sram/save_data lengths, serialized state and debug view.
  The first match is used; if none matches the region is refused (byte 9 is
  written as before, so the bytes and hash are unchanged). The second
  candidate exists because NES 2.0 RAM sizes are taken at their word: the
  v2.9.6 MMC3 multicart boards (12, 37, 45, ...) take their own default on
  iNES 1.0 instead of the nominal 8 KiB, and an "8 KiB" promotion would have
  put work RAM over Super Mario Bros. + Tetris + Nintendo World Cup's outer
  bank register.
* Vs. System / PlayChoice-10 carts are never promoted: those cabinets exist
  only in NTSC timing; a PAL row on one names the home release sharing its
  PRG/CHR (PlayChoice-10 Baseball = Baseball (USA, Europe)). Mapper 99 needs
  the explicit check, since both parses force it to the Vs. System.
* Multi-region rows: the vendored Region column holds only NTSC (2,305) and
  PAL (377); no Dendy, no "multiple". The nine rows titled "(USA, Europe)" --
  one image sold in both markets -- are the only multi-market titles, all PAL.
  They now carry no region, so an iNES 1.0 copy stays at the NTSC default,
  which is what a NES 2.0 "multiple region" header (Ice Climber's and
  Gumshoe's staged copies) gives the same image. Dendy is accepted from the
  user overlay and promoted like PAL.

No per-row correction was needed: every PAL row whose dump is staged was
confirmed or not refuted by its frames (below), so no correction layer was
added. (Note for the record: `game_database.txt` is not a verbatim copy -- the
Seicross row was edited in place by #127.)

Consequence: a promoted image hashes differently, so for an iNES 1.0 game
with a PAL row the frontend's save-state directory and `.sav` name change once.

Evidence (frames before/after, external_coverage, all looked at)
----------------------------------------------------------------
iNES 1.0, NTSC -> PAL (promoted):
  Pin Bot (E) [!]        garbled title -> correct PIN*BOT title    FIXED
  Sidewinder (Sachen) x2 frozen attract (3 identical frames) -> runs  FIXED
  High Speed (E)         same title screen, PAL cycle counts
  SMB + Tetris + NWC (E) (m37, no-RAM candidate) same, renders
  Super Sports Challenge (m232) same team select, renders
  Great Wall (m137)      NTSC sat on EASY/HARD; PAL reaches gameplay, which
                         is garbled -- identically at NTSC, PAL and Dendy when
                         run long enough (coverage_smoke on NES 2.0 variants,
                         600..3200 frames): a pre-existing defect, not region
  Silver Eagle, Dancing Blocks, Master Chu, Millionaire, Pyramid II,
  Twin Eagle, Challenge of the Dragon x2, Mahjong World: render the same
NES 2.0, header region now kept:
  Funblaster Pak (Australia)   PAL header, was forced NTSC -> PAL
  Fan Kong Jing Ying, Mei Guo Fu Hao (Asia)  Dendy header, was NTSC -> Dendy
  Ice Climber, Kid Icarus, Gumshoe, Auto-Upturn, Happy Pairs  "multiple"
    header, was forced PAL -> NTSC timing. All render correctly.
Unchanged: PlayChoice-10 Baseball (arcade, refused).

Verification
------------
Red first: `a_pal_row_reaches_the_core_on_an_ines1_header` failed (Ntsc vs
Pal) before the promotion; `a_nes2_header_keeps_its_own_region` failed
(Some(Pal)) before the NES 2.0 region drop. Mutations, each caught:
  M1 promotion disabled          -> a_pal_row..., header_overrides_rewrite...
  M2 no-RAM candidate removed    -> a_multicart_promotion..., sweep (refuses
                                    12, 37, 45, ... at every layout)
  M3 arcade refusal removed      -> an_arcade_cart_is_never_promoted (m99)
  M4 NES 2.0 region kept         -> a_nes2_header_keeps_its_own_region
  M5 multi-region rule removed   -> a_multi_market_row_carries_no_region
`promotion_never_changes_the_board` sweeps mappers 0-255 x 5 layouts x
battery: no board refused.

Snapshots re-blessed (each attributed: cycle/audio counts move between the
NTSC 17.84M and PAL 19.92M per-600-frame figures, frames looked at):
external_coverage (24), external_extended m137/m138/m143/m145/m147/m150,
external_real_games Kid Icarus + Ice Climber. external_extended m218, m226 and
taito48 also fail on this branch with framebuffer-only diffs; none has a
region change (no DB row for m218/m226; Don Doko Don 2 is NES 2.0 NTSC with
an NTSC row), so they predate this commit and are left for their owners.

Gates: fmt, workspace clippy -D warnings, rustdoc, gamedb + frontend tests,
AccuracyCoin 144/144, nestest 0-diff, markdownlint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
…ibuted

The agents that fixed mappers 218 and 226 and the game-database guard
re-blessed the `external_coverage` baselines for their games, but not these
`external_extended` ones. Those use a different input schedule and
checkpoint (f600) for the same dumps. The region agent found all three
failing on the branch and left them for their owners. In each, only the f600
framebuffer hash moved; cycles and audio are unchanged.

- extended_m218_magic_floor_by_martin_korth: ccd695db, mapper 218's CIRAM
  wiring. Its coverage frame, viewed in 45765dec, is the board and score line.
- extended_m226_76_in_1_p1: 12013e12 (cherry-picked from c64af128), mapper
  226's `[PMOP PPPP]` register. Its coverage frame is the "76 IN 1 GAME" menu.
- extended_taito48_don_doko_don_2: found by `git bisect run` from main
  (e3debc9) to 0b50244b, with a script that restores the old baseline and
  runs only this test. The first bad commit is e94724ee, the database NES 2.0
  guard. The dump's header is NES 2.0, mapper 48 (bytes 6/7 = $00/$38), and
  the vendored database had been running it as another board. Its frames now
  show the Taito title, the overworld map and the title again, as they should.

The whole external_extended suite passes: 137 / 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
doublegate and others added 10 commits October 2, 2026 09:38
`SystemBus::power_cycle` rebuilds the PPU and the APU from `Ppu::new` /
`Apu::new`. It re-applied the settings the bus also stores (die revision,
power-up palette, Vs. RGB palette, the externally driven DMC), but every
setting held only on a chip silently reverted to its default:

  PPU: the custom / generated (NTSC) palette, the extra-scanlines
       overclock, the fast dot path selector, the OAM-decay switch
  APU: the channel mask, the per-channel gain, the filter model

Only the desktop's Power Cycle re-pushed them (fully only since
410b333d). Android and iOS lost a loaded palette on every Power Cycle, and
`power_on_for_movie` re-applied the options a movie records but not the
palette, mask, gain or filter. The bug lived in the core, so every host
inherited it.

Mechanism. The bus now swaps the fresh chip in with `mem::replace` and
hands the old one to a new chip-side method that carries the settings:

- `Ppu::adopt_settings_from(&mut self, prev: &Self)` copies the custom
  palette (and rebuilds the RGBA LUT to honour it), the overclock (through
  `set_extra_scanlines`, which also zeroes the in-flight countdown, as a
  fresh PPU has it) and the fast dot path, and re-enables OAM decay through
  `set_oam_decay`, so the row ages are stamped from the rebuilt PPU's cycle
  0 exactly as a host enabling it on a fresh console would. A new
  `Ppu::custom_palette()` getter backs `Nes::custom_palette()`.
- `Apu::adopt_settings_from` copies the mask and gain and rebuilds the
  filter through `set_filter_model`. The APU stores the built chain, not the
  selected model, so `Apu` gains a plain `filter_model: FilterModel` field
  (set in `set_filter_model`, read by nothing in synthesis) plus
  `Apu::filter_model()` / `Nes::apu_filter_model()`. Nothing in blip.rs
  changed: the BLEP region (Provenance: blip_buf) is untouched, and only
  its existing `set_filter_model` is called.

The list of what a setting is lives next to the fields, so a new one is
carried where it is declared. `snapshot_schema_audit.rs` gains
`every_config_field_survives_a_power_cycle`: every PPU / APU field the
audit classifies as "config:" must be read as `prev.<field>` in
`adopt_settings_from`, or re-applied by a named call in
`SystemBus::power_cycle` (`active_palette`, `die_revision`,
`power_up_palette`, `dmc_driven_externally`). `filter_model` is added to the
APU's exclusion list as config (the chain it builds IS serialized).

The debug-hooks provenance stores (write attribution, pixel provenance,
audio provenance) are moved onto the new chips with the existing
take/put stash pair, so they stay ARMED and `Nes::power_cycle` empties
them, which is what its comment always claimed; before, they were dropped
with the old chips and that clear was a no-op. The state and fetch traces
(cosim capture buffers) are deliberately not carried.

Power-on fills are untouched: RAM and palette-RAM fills still re-apply on
a cycle exactly as before. Bus-held settings were never affected.

Host side, a pure refactor only: `power_on_for_movie` no longer captures
and re-sets the fast dot path by hand (the cycle keeps it); the frontend's
`configure_console` and `do_power_cycle` are unchanged in behaviour (they
still re-attach the expansion device and put the player's configuration
under a running movie's `held_options`), with their comments updated. The
frontend test `configure_console_restores_what_a_power_cycle_drops` had
asserted the old premise (that the cycle drops the chip settings); it now
asserts the cycle keeps them and drops only the expansion device.

Tests (nes.rs):
- `a_power_cycle_keeps_every_ppu_and_apu_setting`: a 7-row table, each
  setting at a non-default value on a bare `Nes`, run a frame, cycle, read
  back. Collects every lost row before failing.
- `a_power_cycled_console_with_settings_runs_as_a_fresh_one`: palette +
  filter, cycled vs fresh nestest, 10 frames: framebuffer, audio and the
  full snapshot equal. Gain and OAM decay are excluded with the measured
  reason: a fresh console receives them only after `from_rom`'s reset
  cycles, the cycled one has them in force for those cycles (measured:
  gain applied to both after the cycle matches, carried through differs).
- `a_power_cycle_keeps_the_provenance_stores_armed` (debug-hooks).

Mutation checks: removing both `adopt_settings_from` calls and the two
provenance puts from `SystemBus::power_cycle` fails all three tests, the
table naming all seven settings; removing only
`self.fast_dotloop = prev.fast_dotloop;` fails the table on that row and
the audit on `fast_dotloop`. Restored, all pass.

Default output is unchanged: every carried value is a selector or an
output override, and at the defaults the carried state equals `new`'s.

Docs: ppu-2c02.md and apu-2a03.md gain a "settings across a power cycle"
note; frontend.md's Power Cycle and power-on movie paragraphs are
corrected; CHANGELOG [Unreleased] Fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
`Nes::mapper_id` returned `self.bus.mapper_debug_info().mapper_id`, the
mapper's DEBUGGER view. `Mapper::debug_info`'s default names mapper 0, and
only boards that override it name their own id, so UxROM (2), CNROM (3),
AxROM (7) and every other board without an override (a `grep -L "fn
debug_info"` over `m0*.rs` alone lists 20 files) reported mapper 0. The NSF
mapper has no override either, so an NSF reported 0 rather than the 31 its
synthetic `Cartridge` carries.

Consumers, all of which want the real id and none of which relied on the
0: the Lua `cart:mapper_id()` (mlua backend), the ROM-info panel (which
compares the active id against the game database's and so flagged a
mismatch on every such board), the mobile `RomInfo`, and
`Nes::expansion_audio_chip`, which used the same debug-view read. The
debugger's mapper panel reads `mapper_info()` directly and showed
"Mapper 0" for the same boards.

Fix:
- `Nes::mapper_id` reads `self.bus.cart.mapper_id` (now `const`): the id
  after any load-time header correction, 20 for an FDS image, 31 for NSF.
- `SystemBus::mapper_debug_info` sets `info.mapper_id = cart.mapper_id`
  alongside the cartridge metadata it already fills (submapper, tier,
  sizes), so the debugger view agrees.
- `Nes::expansion_audio_chip` matches on `self.mapper_id()`. Checked: every
  board that overrides `mix_audio` (MMC5 5, N163 19, VRC6 24/26, FDS 20,
  5B 69, VRC7 85) already named its id in `debug_info`, and NSF falls to
  the generic label under both 0 and 31, so no label changes.
- New `Nes::submapper()` reads the NES 2.0 submapper the same way.

Test: `mapper_id_reports_the_cartridge_mapper_on_every_board` builds NES
2.0 images for mappers 0, 2, 3, 7 and 4 with submappers and asserts
`mapper_id`, `submapper` and `mapper_info().mapper_id`. Red before the fix
("mapper 2: left 0, right 2"). Mutation: dropping the
`info.mapper_id = cart.mapper_id;` line fails the view assertion
("mapper 2 view"); restored, it passes. The NROM-based Lua test
(`cart_queries_report_metadata`, id=0) and the MMC3 probe (4) are
unaffected. No emulation path reads the id this changes.

Docs: scripting.md's `cart:mapper_id()` row; CHANGELOG [Unreleased] Fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014qfTKi2M3swo7qnwvYCkDj
The desktop has two ROM load paths. `load_rom_from_path` (the menu's Open
ROM, drag-and-drop and File > Recent all call it) asked
`build_dual_cabinet` and installed a Vs. DualSystem image as the
two-console cabinet (`EmuCore::set_dual`). The startup path -- a ROM given
on the command line: `App::new` -> `resumed` -> `start_nes` ->
`finish_start_nes` -- never asked, and installed every image with
`set_nes`, so `rustynes balonfgt.nes` ran the main CPU alone and could not
complete the boot handshake with the sub. The browser's `install_nes_wasm`
asked, with its own copy of the FDS exclusion.

Mechanism. One decision, `App::cabinet_for_image(&self, nes, bytes,
sample_rate)`: NSF (native) and FDS images return `None` by their magic,
everything else goes to the existing `build_dual_cabinet` (which applies
the Vs. database, the game-database correction and `configure_console` to
both consoles). All three load paths call it:

- `load_rom_from_path`: replaces its inline NSF/FDS check + call.
- `install_nes_wasm`: replaces its inline FDS check + call.
- `finish_start_nes` (native): now takes `sample_rate` from `start_nes`
  (all three call sites pass it), asks for a cabinet first, and mirrors the
  menu path: a cabinet's consoles get no cheats (`raw_cheats` stays as it
  was), `dual_mode` is set for the present branch, `present_fb_sub` is
  cleared, and the cabinet is installed with `set_dual` in place of
  `set_nes`. The probe console still supplies the frame duration and is
  otherwise discarded, as on the menu path. The battery attach runs the
  same way for both.

Test: there is no behavioural seam -- these functions need an event loop,
a window and `&App`, which no unit test constructs -- so, following the
existing `every_load_path_configures_the_console_before_installing_it`
pattern (production half only, test module cut off),
`every_load_path_installs_a_dual_system_cabinet` asserts that each of the
three load functions asks `self.cabinet_for_image(` before a `.set_dual(`,
that `open_rom_dialog` and `start_nes` still route through them, and that
`self.build_dual_cabinet(` is called exactly once in production code, from
`cabinet_for_image`. Red before the change ("load_rom_from_path: does not
ask `cabinet_for_image`"). Mutation: replacing the `finish_start_nes`
decision with `None` fails it ("finish_start_nes: does not ask
`cabinet_for_image`"); restored, it passes.

Not changed: the drag-and-drop and Recent paths already reached the
cabinet through `load_rom_from_path`; a cabinet is still excluded from
rewind, run-ahead, netplay, TAS and the debugger (ADR 0032).

Gates for this commit: both wasm clippy gates clean.

Docs: frontend.md's DualSystem paragraph names the one decision and the
three paths; CHANGELOG [Unreleased] Fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found while pinning the Vs. DualSystem power cycle against a fresh cabinet:
even a single `Nes` that had polled `$4016`/`$4017` did not equal a fresh
boot after `power_cycle`. The snapshots differed at byte 2133, the bus
section's `port_read_cycle: [u64; 2]` -- 20 for port 0 on the cycled
console, `u64::MAX` (never read) on the fresh one.

`port_read_cycle` holds the bus cycle of each port's last read, for the
v2.6.5 CLK-run model (`port_continues_run`: a read continues a run when
`cycle == last + 1`). `SystemBus::power_cycle` resets `self.cycle` to 0
and resets the controllers, the Four Score chain and the deferred strobe
write, but left these stamps from the old timeline. So the cycled state
depended on how long the console had run -- breaking the `power_cycle ==
fresh boot` equivalence the netplay determinism tests rely on (peers
power-cycle at session start from different states) -- and a stale stamp
could, in principle, match the new clock and make the first read of the new
boot count as continuing a run.

Fix: `self.port_read_cycle = [u64::MAX; 2];` in `power_cycle`, beside the
controller-write reset, matching `with_sample_rate`'s initial value. The
edit is in `power_cycle`, outside bus.rs's disclosed TriCNES regions (the
OAM-DMA register-window read and the DMA state).

Test: `a_power_cycle_after_controller_reads_is_a_fresh_boot` runs a
synthetic NROM looping `LDA $4016 / LDA $4017` for three frames (the
first `run_frame` on a fresh console ends after the 8 reset cycles, so one
frame does not reach the loop -- measured), asserts the premise that port
0 has a stamp, power-cycles and compares the full snapshot with a fresh
console's. Red before the fix ("a power-cycled console must equal a fresh
one"), green after; reverting the one line is the mutation.

No golden or test ROM power-cycles mid-run, so no recorded output changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Two defects, one in the core and one on the desktop.

Desktop. `App::do_power_cycle` acted on `emu.nes` only. A cabinet lives in
`emu.dual`, and `EmuCore::set_dual` empties `emu.nes`, so with a cabinet
loaded F3 and Emulation > Power Cycle did nothing to the emulator (the
timeline and lag tally were cleared, the consoles kept running).

Core. Cycling a cabinet's consoles one by one -- the only primitive there
was, and what the mobile bridge's fallback did -- is not a cabinet power
cycle. `Nes::power_cycle` rebuilds each mapper from the ROM, which drops the
wiring `VsDualSystem::from_pair` installed on it: the sub's second-half
PRG / upper-CHR banking (`set_vs_dual_sub`) and both consoles' shared 2 KiB
WRAM window (`enable_vs_dual_wram`). The sub then runs the MAIN program,
fails its `$4016` identity check, and the boot handshake never completes.

Fix:
- `VsDualSystem::power_cycle()` power-cycles both consoles, empties the
  two scratch buffers, and re-runs the cabinet wiring. The wiring moved out
  of `from_pair` into a private `wire()` that both call, so a power-cycled
  cabinet is wired by the same code as a new one. The moved block's
  comments, which cite Mesen2's `VsControlManager::Reset` and MAME's
  `.share("nvram")` as behavioural references, are carried verbatim; this
  file has no `// Provenance:` header and the change adds no derived code.
- `do_power_cycle`: when `emu.dual` holds a cabinet, call
  `dual.power_cycle()`, then for each console (via `split_mut().into()`,
  as `build_dual_cabinet` does) `configure_console(&self.config, console)`
  and then a running movie's `held_options().apply(console)` -- the same
  path and precedence as the single console above it. Cheats and debugger
  telemetry have no cabinet counterpart (ADR 0032).
- Mobile: the `power_cycle` fallback (taken only if rebuilding the cabinet
  from its ROM fails) now calls `VsDualSystem::power_cycle` instead of
  cycling the consoles one by one; its rustdoc says so.

Tests:
- Core, `a_cabinet_power_cycle_is_a_fresh_cabinet` (vs_dualsystem_synth):
  run the synthetic handshake cart, power-cycle, compare the RVSD snapshot
  with a freshly built cabinet's, then run five frames and require all
  five protocol markers on both consoles. Red with the naive
  implementation (cycle main, cycle sub): "a power-cycled cabinet must
  equal a freshly built one". It stayed red after the re-wiring until the
  separate port-stamp fix (previous commit), which was the remaining
  difference (byte 2133 of each console). Green now.
- Desktop, `the_power_cycle_cycles_and_configures_a_dual_system_cabinet`
  (source shape, `do_power_cycle` needs `&mut App`): `dual.power_cycle();`
  precedes `configure_console(&self.config, console)` precedes
  `emu.movie.held_options()`, and no `main_mut()/sub_mut().power_cycle()`.
  Red before ("a cabinet is not power-cycled"); mutation removing
  `dual.power_cycle();` fails it the same way; restored, it passes.

Found, not changed: the mobile bridge's PRIMARY cabinet path rebuilds the
cabinet from its ROM, which drops battery-backed RAM (a Power Cycle has
kept it since v2.9.0 on a single console) and any loaded palette.
`VsDualSystem::power_cycle` would keep both; switching is a mobile-scope
decision, and the Swift / Kotlin sides are unverified on a device.

Gates for this commit: frontend clippy (native, both wasm targets) and
mobile clippy clean.

Docs: frontend.md Power Cycle paragraph; CHANGELOG [Unreleased] Fixed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A documentation pass over the root files and the docs the release changed,
done before the cut so the cut commit stays a version bump.

- CHANGELOG [Unreleased]:
  - a "Breaking changes at a glance" lead, which says why the breaks ship as
    v2.9.8 (maintainer: preparation for v3.0.0);
  - two Added entries that were missing: the MiSTer core's new menu options,
    and the real-game coverage work (44 dumps pinned, 748 frames reviewed).
- README:
  - the Roadmap no longer says v3.0.0 will remove the 2.7.5 APIs; those breaks
    landed in v2.9.8;
  - the netplay and save-state highlights say what movies and peers now carry;
  - the Cartridges list gains the every-platform header corrections, the
    header-independent save identity, and the optional Famicom console model.
  - The "Current release" paragraph is left to the cut, because
    release_anchor_audit pins it to the Cargo version.
- AGENTS.md:
  - It called v2.0.0 the project's "single designated breaking release" in
    three places. It now records both breaks: v2.0.0, and v2.9.8 carrying
    v3.0.0's breaks early. It notes that v3.0.0's notes must restate them.
  - The docs/agents index counts were stale: measurement-discipline 20 -> 21,
    tooling-traps 13 -> 19, dependencies 2 -> 3.
- docs/agents/tooling-traps.md, six v2.9.8 traps:
  - a gitignore pattern with a trailing slash does not match a symlink;
  - the shared, unconfigurable frame-dump directory;
  - KNOWN_BLANK's ratchet misreporting after a hash change;
  - zsh `set -- $var` not splitting;
  - a 1Password outage blocking cherry-picks;
  - telling parallel agents when the tip moves.
- docs/ppu-2c02.md still described the bus-side /NMI edge detector running in
  `tick_one_cpu_cycle`, both removed by ADR 0042. It now states the current
  path: the CPU samples `nmi_level` once per cycle and edge-detects it itself
  (`ppu_vbl_nmi_07_nmi_on_timing` pins it). The history is kept as one
  sentence.
- docs/pixel-provenance.md said the movie format "stays at 2", which read as
  current. It is now past tense, with a pointer to format 3 (ADR 0044).
- .gitignore: `/tests/roms/external` without the trailing slash, so the
  symlink each agent worktree creates is ignored. Verified with
  `git check-ignore -v` on a real symlink in a scratch repo.

Checked and left alone: the hits on removed API names in historical records
(release notes, VERSION-PLAN rows, STATUS history, performance records) are
history and stay as written. docs/apu-2a03.md, docs/cartridge-format.md and
docs/frontend.md already described the removals correctly. markdownlint
passes on every edited file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The game database's PAL region now reaches iNES 1.0 dumps (0b50244b). Two
committed screenshots were taken while these games ran at NTSC timing:

- Pin Bot (Europe).png showed the garbled title of the (E) dump, its CHR-RAM
  upload overrunning NTSC vblank. It now shows the clean Rare title.
- Sidewinder (Sachen).png showed the game frozen. It now shows the title
  screen.

Both are the final frame of the full external_coverage sweep on 734edd40,
looked at before copying. Their .snap baselines were re-blessed, with the
change attributed, in 0b50244b.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…paign

Plan items 1-3: re-measure the leads that the pre-v2.9.1 ab_check.sh had
"measured" with the reference binary on both sides. Eleven candidates were each
rebuilt and measured twice with the fixed tool, every run started on a quiet
host (one-minute load under 1.5). The full table, including the rejections, is
in docs/performance.md. Three pass the evidence rule: reproduced, p < 0.05,
consistent sign, a clean order-bias control, and a shipped `_fast` path moves.

- D3, crates/rustynes-apu/src/apu.rs: the per-cycle mix compared six `f32`s
  against CHANNEL_GAIN_UNITY on every CPU cycle. A cached `gain_is_unity` flag,
  recomputed by `set_channel_gain` (the only writer of the gain), replaces the
  comparison. -3.1% to -4.5% on all four frame workloads, both runs; control
  drift at most 1.1%.
- G3, crates/rustynes-ppu/src/ppu.rs: `tick_sprite_eval_per_dot` computed
  `next_line` and `sprite_height` on every dot, and they are dead on 149 of
  341. They now sit in the `65..=256` arm, their only use. -1.0% to -3.1% on
  all four, both runs. This reverses a recorded verdict: v2.3.1 measured G3 as
  "no change" with the defective tool, and a comment forbade re-trying it. The
  comment now says what happened and cites the new measurement. The function
  is inside ppu.rs's disclosed Mesen2-derived sprite-evaluation region; this is
  a move of our own existing code within it, so the header and the provenance
  record are unchanged and no new expression is introduced.
- D1, crates/rustynes-apu/src/apu.rs: `dmc_tick_end` read
  `self.dmc.bits_remaining()` twice in a row. It now reads it once.
  -1.2% on the shipped palette path, -2.5% to -3.0% on exact `nestest`, both
  runs. Shipped `nestest` does not move.

A bug caught while applying D3, before it shipped. `Apu::adopt_settings_from`,
the power-cycle carry added earlier in this release (05ceec50), assigned
`channel_gain` directly. With D3 in place the fresh APU's cached `true` would
have survived, and the mix would have ignored a non-unity gain after every
Power Cycle. It now goes through `set_channel_gain`.
`a_power_cycle_keeps_a_non_unity_gain_audible` checks two things: the flag, and
that a power-cycled APU's samples equal a directly configured APU's. Mutation:
putting the field copy back fails the test; restoring the fix passes it.
`snapshot_schema_audit` classifies the new field as derived.

Rejected: the palette read view (shipped `nestest_fast` +0.2* / -3.0 / +1.0
over three runs, mixed sign) and G2 `#[repr(C)]` (mixed sign). Ceilings with
room for later correct candidates: G9, D4, D2.

Verification: `cargo test --release --workspace --features
test-roms,commercial-roms --no-fail-fast` gives 3,358 passed, 0 failed,
19 ignored (one more than before: the new test). That includes AccuracyCoin
144/144, nestest 0-diff, every golden and every commercial suite with the
coverage sweep. No snapshot or golden changed. fmt and workspace clippy are
clean. The three together are measured as one A/B next, against this
commit's parent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s not add up

docs/performance.md gains three v2.9.8 sections:

- The /NMI edge detector's removal (63850aa7), measured on its own against its
  parent in two runs: -4.9% to -6.1% on three workloads and -1.4% on exact-path
  nestest. Run 2's order-bias control drifted at most 1.8%. This agrees with
  v2.9.1's ceiling probe and with ADR 0042's estimate. The section's older
  'to be measured' sentence now points here.
- The 11-candidate campaign. Each candidate was measured twice on a quiet host,
  correct candidates and ceilings tabulated, with the max control drift and a
  verdict per row. D3, G3 and D1 are adopted (599d5ade). The palette read view
  and G2 are rejected for mixed sign. The G9, D4 and D2 ceilings show room. G4,
  G5 and G6 are not interpretable, each for a stated reason. The three adopted
  together measure -1.8% to -4.3% on all four workloads, in two runs. Every
  figure was checked against the raw run logs; a first draft had misfiled G4's
  +8.9% candidate number as control drift.
- The release end to end against v2.9.7. Only the shipped nestest_fast path is
  established (-5.5% to -8.7%, both runs). Run 1's gains on the other three
  workloads did not reproduce in run 2. Since two steps each measured clean
  gains on every workload, something else in the release offsets them there.
  It is recorded as found, not investigated, with the bisect to run in v2.9.9.
  No release-wide speed-up is claimed beyond the established path.

The CHANGELOG gains a matching Changed entry. The v2.9.8 release notes' PERF
placeholder becomes the same four plain statements. The significance markers in
the new tables are escaped (markdownlint read them as emphasis).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Moves the workspace, the excluded co-simulation crate and the libretro .info
to 2.9.8. Every release anchor that names the current version is rewritten by
scripts/release-automation/bump_release.py (dry run, then --apply), with a
lead naming what the release did. Cargo.lock changes only its workspace
version lines.

Codename. The plan chose "Cadence", but v2.3.3 already owns that name. The
maintainer chose "Vanguard" at the cut (2026-10-02), as v2.7.5 was once
renamed away from "Ledger". The plan file is renamed to
v2.9.8-vanguard-plan.md and records why. Every v2.9.8 mention is updated;
v2.3.3's own mentions are untouched. The branch keeps its name,
feat/v2.9.8-cadence.

Hand edits the script asks for, by design:
- VERSION-PLAN gains the v2.9.8 row (current), v2.9.7 is demoted, and v2.9.8
  leaves "Planned next".
- The to-dos/ROADMAP.md release chain gets its v2.9.8 entry, and v2.9.7 loses
  "the current release".
- The root ROADMAP.md "Project Status" line is rewritten. The script had kept
  v2.9.7's description under v2.9.8's name, which is exactly the defect its
  own docstring describes.
- docs/STATUS.md's top-line count moves to v2.9.8, and its ignored-test
  catalogue drops the 7 deleted scheduler pins: 14 ignored, 1 of them the
  emphasis table's generator.
- to-dos/plans/README.md gains the v2.9.8 row, and the line plan's status
  moves.

CHANGELOG: [Unreleased] became the dated [2.9.8] section, with a lead and a
Verification section; an empty [Unreleased] stays above it. The rebase onto
#578 (81c09c2, the iOS app fix) put that PR's [Unreleased] entries into this
section, because #578 ships in v2.9.8. The section was rebuilt from the
verified pre-rebase text plus those six entries. A paragraph-union resolver
had left a stale intermediate sentence and broken blank lines across 25
conflict resolutions, so it was not trusted.

The release notes (.github/release-notes/v2.9.8.md) open with the breaking
changes, gain an "On iOS" section for #578, and carry the measured
performance. The bitstream line is filled in at attach time, because the
datecoded name and the md5 follow the merge date.

Verification on this tree, after the rebase:
- cargo test --release --workspace --features test-roms --no-fail-fast:
  3,139 passed, 0 failed, 14 ignored (v2.9.7: 3,065 / 0 / 20). That includes
  the release audits (release_anchor_audit and the rest), AccuracyCoin
  144/144 and nestest 0-diff.
- With commercial-roms, before the rebase: 3,358 / 0 / 19, the full
  external_coverage sweep included. The rebase changed only #578's files (iOS
  Swift, CI workflows, an AccuracyCoin README rename, one test comment),
  verified by diffing the branch against its pre-rebase backup.
- fmt, clippy for every feature set and both wasm builds, rustdoc, the
  no_std build and markdownlint were clean on the integrated tree.
- MiSTer: ladders 175/0/1 on-die and 176/0/1 off-die; both builds re-swept,
  seed 2 kept, two clean compiles of each byte-identical.
  No hardware has run any bitstream.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 2, 2026 14:03
@doublegate

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: Repository: doublegate/RustyNES/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 6a99045c-3be2-41b5-91b0-a50e1331f7d9
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review skipped: 180 files exceed the limit of 100.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Antigravity review (Gemini via Ultra)

This PR prepares the codebase for v3.0.0 by preemptively introducing its planned breaking changes to the public API, save/movie formats, and ROM identity logic, alongside comprehensive rendering bugfixes and new MiSTer core menu options.

Blocking issues

  • Breaking changes without a version bump: The PR introduces intentional breaking changes to the public API (renaming LockstepBus to SystemBus, removing deprecated methods, making Header and FrameInput #[non_exhaustive]) and wire format (netplay PROTOCOL_VERSION 5) in a patch release (v2.9.8), violating the style guide's rule against breaking changes without a major version bump.
  • Data loss / On-disk format breaks: Changing Nes::rom_sha256 to ignore the header intentionally orphans users' existing .sav battery files, cheats, and save states, causing silent data loss of in-game progress. Additionally, .rns epoch 3 explicitly refuses older save states and .rnm format 3 refuses older movies. These on-disk format breaks must accompany a major version bump (v3.0.0) per the style guide.

Suggestions

  • crates/rustynes-core/src/movie.rs: The new FrameInput::port and port_mut methods panic! if port > 3. Per the "typed results over panics" rule, consider returning Option<Buttons> and Option<&mut Buttons> instead of panicking on invalid external input.
  • crates/rustynes-core/src/movie_interop.rs: Avoid swallowing fmt::Error from writeln! (e.g., let _ = writeln!(out, ...) in export_fm2). Even though String formatting rarely fails, ignoring the returned result violates the "silent failure paths" guideline. Handle it properly or use ?.

Nitpicks

  • The PR title (v2.9.8 "Vanguard" -- ...) does not follow the Conventional Commits format (feat|fix|...) required by the style guide.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Earlier review rounds (newest first)
Round reviewed at 2026-10-03 09:47 UTC

Antigravity review (Gemini via Ultra)

This PR prepares for v3.0.0 by landing breaking API and on-disk format changes early, fixing rendering defects across the game library, and adding new display options to the MiSTer core menu.

Blocking issues

  • Breaking changes without a version bump: This PR is a patch bump (v2.9.7 → v2.9.8) but introduces intentional breaking changes to the public API (LockstepBus renamed to SystemBus, removals of deprecated items) and on-disk formats (ROM identity hash changes break old saves and cheats, older save states and movies are refused). This violates the project style guide's rule against breaking changes without a version bump; these changes must be held for the actual v3.0.0 release.

Suggestions

  • crates/rustynes-test-harness/tests/famicom_console.rs: The call let _ = std::fs::create_dir_all(dir); silently swallows directory creation errors, which could mask I/O failures in the test suite. Use .unwrap() or .expect("Failed to create dir") instead.

Nitpicks

  • crates/rustynes-core/src/movie_interop.rs: let _ = writeln!(out, ...); swallows formatting errors. While I note the planning document calls this a "workspace idiom," it technically creates silent failure paths.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Round reviewed at 2026-10-03 08:10 UTC

Antigravity review (Gemini via Ultra)

This PR implements breaking API and format changes ahead of the v3.0.0 release (renaming LockstepBus to SystemBus, bumping save/movie epochs, and hashing ROMs without their headers), alongside accuracy fixes for various mappers and new visual options for the MiSTer core.

Blocking issues

  • Breaking API changes without a major version bump: The PR introduces breaking API changes (e.g., renaming LockstepBus to SystemBus, making Header #[non_exhaustive], removing deprecated APIs) and breaks on-disk formats (refusing older save states and movies). Although the release notes acknowledge these as "v3.0.0's breaks landed early", introducing them in a patch/minor release (v2.9.8) explicitly violates Semantic Versioning for any downstream consumers of rustynes-core.
  • Data loss (abandoned battery saves): Changing the persistent ROM identity (Nes::rom_sha256) to exclude the header means existing battery .sav files and save states will not be found. Without an automatic fallback or migration to rename the old files to the new hash, users will effectively lose their progress upon upgrading.

Suggestions

  • Add a fallback mechanism in the save-loading logic that attempts to find and rename .sav files and save states using the old image_sha256 hash if the new rom_sha256 hash yields a miss. This preserves user data transparently.
  • Ensure the error messages for refused v2.9.7 save states and older movies are cleanly surfaced in the frontend UI, rather than just returning an error type that might be swallowed or only logged.

Nitpicks

  • The .gitignore update for /tests/roms/external could simply add the path itself; the four-line explanation is better suited for the commit message.
  • Consider explicitly adding the Famicom console model context to the user-facing manual so users understand when it might be necessary.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Round reviewed at 2026-10-03 06:41 UTC

Antigravity review (Gemini via Ultra)

This PR introduces v3.0.0 breaking changes ahead of schedule (altering ROM identity checks, refusing old save states/movies, and breaking the public API), standardizes game database corrections across all platforms, and adds new aspect ratio and scaling options to the MiSTer core.

Blocking issues

  • Breaking changes without an appropriate version bump: The PR renames LockstepBus to SystemBus, removes APIs deprecated since v2.7.5, makes Header and FrameInput #[non_exhaustive], and alters on-disk/wire formats (refusing older save states and movies, and updating the netplay protocol). Releasing these in a patch version (v2.9.8) rather than a major version bump (v3.0.0) violates Semantic Versioning, breaks downstream consumers, and directly violates the project style guide.

Suggestions

  • crates/rustynes-netplay/src/message.rs: When IdentityMismatch::Config is returned from SessionIdentity::check(), consider exposing or logging the differing config_hash values so that developers and users can more easily diagnose which emulation options are mismatched.

Nitpicks

  • crates/rustynes-frontend/src/app.rs: The test live_settings_never_rerun_the_power_on_ram_fill validates behavior by parsing its own source code as strings via include_str!("app.rs"), which is functional but brittle to reformatting and minor structural refactoring.
  • crates/rustynes-core/src/bus_snapshot.rs: The loops deserializing arrays (e.g., for p in &mut pending { *p = r.bool()?; }) could be simplified if BinReader exposed a direct array/slice reading method.

Automated first-pass review by agy on a self-hosted runner -- not a human review.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

One or more issues must be addressed before approval.

Review effort: Balanced
Findings: None

What changed in this PR

Updates Vanguard’s documentation, release metadata, regression baselines, and API callers to support the early compatibility breaks and cross-platform database corrections.

Changes:

  • Document API migration, configuration-aware netplay, database corrections, and provenance.
  • Refresh visual/commercial regression baselines and remove unsupported-dump baselines.
  • Update versions, adapt callers, and explicitly reject unsupported Switch builds.
File Description
to-dos/​DEFERRED-AND-CARRYOVER-FEATURES.md Updated as part of this pull request.
screenshots/​mmc1_a12/​README.md Updated as part of this pull request.
screenshots/​m22/​README.md Updated as part of this pull request.
screenshots/​besteffort/​README.md Updated as part of this pull request.
NOTICE Updated as part of this pull request.
docs/​ppu-trace-tooling.md Updated as part of this pull request.
docs/​netplay-webrtc.md Updated as part of this pull request.
docs/​libretro/​implementation_specifics.md Updated as part of this pull request.
docs/​ios.md Updated as part of this pull request.
docs/​benchmarks.md Updated as part of this pull request.
docs/​architecture.md Updated as part of this pull request.
docs/​android.md Updated as part of this pull request.
docs/​adr/​0043-v3-is-the-api-major-and-a-release-candidate-core.md Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​v21_coverage_mappers.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​terminus_control.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​visual_regression__full_palette_frame_60.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​visual_regression__full_palette_frame_180.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​visual_regression__flowing_palette_frame_60.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​visual_regression__flowing_palette_frame_300.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​visual_regression__flowing_palette_frame_180.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​m22__m22_vrc2a_chr_banking_0_127.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_real_games__external_nrom_ice_climber.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_real_games__external_mmc1_kid_icarus.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_taito48_don_doko_don_2.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_mmc3_felix_the_cat.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m72_doraemon_world_3_by_kiku.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m244_asmik_kun_land_t1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m226_76_in_1_p1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m221_1000_in_1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m218_magic_floor_by_martin_korth.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m150_auto_upturn.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m147_challenge_of_the_dragon.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m145_sidewinder.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m143_dancing_blocks.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m138_silver_eagle.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m137_great_wall_the.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_extended__extended_m112_chik_bik_ji_jin_saam_gwok_ji.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_249_WaixingT9552_Shui_Hu_Zhuan_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_249_WaixingT9552_San_Shi_Liu_Ji_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_249_WaixingT9552_Myth_Struggle_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_244_Decathlon_Asmik_kun_Land_J_t1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_228_Action52_Cheetahmen_II_USA_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_228_Action52_Cheetah_Men_II_Active_Enterprises_p.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_228_Action52_Action_52_Active_Enterprises.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_226_BMC_76in1_Super_42_in_1_22_Games_p1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_226_BMC_76in1_76_in_1_p1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_218_MagicFloor_Magic_Floor_by_Martin_Korth_2012_PC10_Version_PD.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_218_MagicFloor_Magic_Floor_by_Martin_Korth_2012_PC10_Version_PD_a1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_191_Waixing191_Mighty_Final_Fight_J_T_Chi_madcell.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_177_Hengedianzi_Mei_Guo_Fu_Hao_American_Man_Asia_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_163_NanjingFC001_Yu_Gi_Oh_NJ032_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_163_NanjingFC001_Qi_Guo_Da_Zhan_NJ029_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_163_NanjingFC001_Ne_Zha_Chuan_Qi_NJ036_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_163_NanjingFC001_Hu_Lu_Jin_Gang_NJ039_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_163_NanjingFC001_Han_Liu_Bang_NJ013_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_152_Bandai74161_Saint_Seiya_Ougon_Densetsu_Japan_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_152_Bandai74161_Gegege_no_Kitarou_2_Youkai_Gundan_no_Chousen_Japan_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_151_KonamiVS_Goonies_The_VS_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_150_Sachen74LS374N_Lightgun_Game_2_in_1_Cosmocop_Cyber_Monster_Asia_Ja_Unl_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_150_Sachen74LS374N_Happy_Pairs_Asia_Ja_PAL_Unl_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_150_Sachen74LS374N_Auto_Upturn_Asia_Ja_PAL_Unl_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_148_SachenSA0037_Mahjong_World_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_147_Sachen3018_JV001_Challenge_of_the_Dragon_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_147_Sachen3018_JV001_Challenge_of_the_Dragon_Sachen_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_146_Sachen_NINA_Twin_Eagle_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_146_Sachen_NINA_Pyramid_II_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_146_Sachen_NINA_Millionaire_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_145_SachenSA72007_Sidewinder_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_145_SachenSA72007_Sidewinder_Sachen_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_143_SachenTCA01_Dancing_Blocks_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_140_Jaleco140_Youkai_Club_Japan_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_138_Sachen8259B_Silver_Eagle_Sachen_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_137_Sachen8259D_Great_Wall_The_Sachen.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_121_KashengA9711_Mortal_Kombat_6_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_119_TQROM_Pin_Bot_E.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_119_TQROM_High_Speed_E.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_113_NINA006_113_Funblaster_Pak_Australia_Unl_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_105_NES_EVENT_Nintendo_World_Championships_1990_U.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_091_JYCompany91_Super_Mario_Kart_Rider_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_091_JYCompany91_Street_Fighter_III_18_Fighter_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_091_JYCompany91_Mortal_Kombat_II_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_091_JYCompany91_Mario_Rider_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_091_JYCompany91_Dragon_Ball_Z_Super_Butouden_2_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_083_Cony_Yoko_World_Heroes_2_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_083_Cony_Yoko_Street_Fighter_X_Turbo_40_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_083_Cony_Yoko_Street_Blaster_II_Pro_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_083_Cony_Yoko_Fatal_Fury_2_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_074_Waixing43_393_Young_Chivalry_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_074_Waixing43_393_Ji_Jia_Zhan_Shi_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_074_Waixing43_393_Feng_Yun_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_072_Jaleco72_Doraemon_World_3_by_Kiku_Doraemon_Hack_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_066_GxROM_Gumshoe_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_064_TengenRAMBO1_Balloon_Fight_J_T_Rus_Mario_Soft_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_048_TaitoTC0690_Bakushou_Jinsei_Gekijou_3_Japan.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_045_GA23C_Super_3_in_1_p1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_045_GA23C_Famicom_Yarou_54_Unl.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_024_VRC6_Pulsewave_Invite_2009_08_xx_PD_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_019_Namco163_Digital_Devil_Story_Megami_Tensei_II_Japan_Rev_A_zip.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_012_GouderSL5020B_Dragon_Ball_Z_Super_Ch_f1.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_012_GouderSL5020B_Dragon_Ball_Z_5_Ch.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_007_AxROM_Cobra_Triangle.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_004_MMC3_Felix_the_Cat.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_001_MMC1_Kid_Icarus.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​snapshots/​external_coverage__mapper_000_NROM_Ice_Climber.snap Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​one_clock_invariants.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​feature_flag_audit.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​external_extended.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​cpu_interrupts_v2.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​tests/​cosim_manifest_audit.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​src/​coverage.rs Updated as part of this pull request.
crates/​rustynes-test-harness/​src/​bin/​coverage_smoke.rs Updated as part of this pull request.
crates/​rustynes-ppu/​src/​raw_signal.rs Updated as part of this pull request.
crates/​rustynes-ppu/​src/​provenance.rs Updated as part of this pull request.
crates/​rustynes-ppu/​src/​lib.rs Updated as part of this pull request.
crates/​rustynes-netplay/​tests/​relay_loopback.rs Updated as part of this pull request.
crates/​rustynes-netplay/​tests/​nat_loopback.rs Updated as part of this pull request.
crates/​rustynes-netplay/​src/​lib.rs Updated as part of this pull request.
crates/​rustynes-mappers/​src/​mapper.rs Updated as part of this pull request.
crates/​rustynes-mappers/​src/​m151_konami_vs.rs Updated as part of this pull request.
crates/​rustynes-mappers/​src/​m078_irem_jaleco78.rs Updated as part of this pull request.
crates/​rustynes-libretro/​rustynes_libretro.info Updated as part of this pull request.
crates/​rustynes-libretro/​Makefile Updated as part of this pull request.
crates/​rustynes-libretro/​Cargo.toml Updated as part of this pull request.
crates/​rustynes-frontend/​src/​input_macros.rs Updated as part of this pull request.
crates/​rustynes-cosim/​Cargo.toml Updated as part of this pull request.
crates/​rustynes-core/​src/​scheduler.rs Updated as part of this pull request.
crates/​rustynes-core/​src/​legacy_movie.rs Updated as part of this pull request.
crates/​rustynes-core/​src/​genie.rs Updated as part of this pull request.
crates/​rustynes-apu/​src/​lib.rs Updated as part of this pull request.
crates/​rustynes-apu/​benches/​apu_throughput.rs Updated as part of this pull request.
Cargo.toml Updated as part of this pull request.
android/​app/​src/​main/​java/​com/​doublegate/​rustynes/​MainActivity.kt Updated as part of this pull request.
.gitignore Updated as part of this pull request.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@context7

context7 Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Docs7 for doublegate/rustynes

Result Status Action
Deployment ➖ Not used —
Content review ⚠️ Incomplete. Docs7 did not review every changed file. View run

Commit 6ee34b1

@doublegate

Copy link
Copy Markdown
Owner Author

Answers to the Antigravity review of 236ce4b:

Blocking: breaking changes without a major bump. Declined. This is the maintainer's decision (2026-10-01): the breaks land in v2.9.8 as preparation for v3.0.0, and the number stays v2.9.x. That decision is recorded in three places. ADR 0042 and ADR 0043 carry dated amendments. The line plan's v3.0.0 section requires v3.0.0's release notes to restate every break. The CHANGELOG [2.9.8] section and .github/release-notes/v2.9.8.md open with "Breaking changes at a glance", so no reader learns of a break from a failure. Downstream exposure is small: the crates are not published to crates.io, and the netplay and file-format breaks fail loudly (SnapshotError::FormatTooOld, the movie format check, PROTOCOL_VERSION 5) rather than misreading old data.

Suggestion: show the differing config_hash on IdentityMismatch::Config. Not taken. config_hash is a one-way SHA-256 over the region, the board and every HardwareOptions field. Printing two digests tells a player that the setups differ, which the variant already says, but not which option differs. Naming the option would mean sending the options themselves in the handshake, which is a protocol change rather than a logging change. Recorded as a follow-up idea, not done here.

Nitpick: live_settings_never_rerun_the_power_on_ram_fill reads app.rs. Kept deliberately. The behaviour lives on App, which needs a window and a GPU device, so no unit test can construct it. The test collapses whitespace before matching, so rustfmt cannot break it. It also fails loudly, naming the branch, when the structure it checks moves. It guards the defect fixed in 9c33bf9 (a live settings toggle re-running the power-on RAM fill).

Nitpick: a BinReader slice reader for the bus_snapshot.rs loops. Not taken in a release cut. These are four short loops, and a new reader method would touch the snapshot format code for no behaviour change.

doublegate and others added 2 commits October 3, 2026 02:36
CodeRabbit refused release PR #579: it has 180 reviewable files, and the
limit is 100. The maintainer chose review-only slices: #580 carries the
core crates (87 files) and #581 the rest (91). Both are byte copies of
this branch at 236ce4b. This commit answers all nine of #580's findings,
seven inline and two outside the diff. Every behaviour fix has a test
that failed before the fix.

A refused movie changed the running game (movie.rs). seek_to_start
applied the movie's HardwareOptions before reaching the start point.
`apply` refills work RAM and palette RAM, and installs the console
model, revisions, Four Score and Game Genie codes. So a movie refused
afterwards still left the player's game modified: an old start state
(StartStateTooOld), a malformed one, or an undecodable code
(OptionNotApplicable). The mutating half is now enter_start. Before
calling it, seek_to_start captures the options and a snapshot. On error
it applies the player's options and then restore_quiet()s the snapshot,
in that order, because `apply` writes the fills and the restore must put
the game's RAM back over them. Test: a_refused_start_leaves_the_player_
untouched drives both failure paths on a player with a non-default fill
and the Four Score, run for a frame. It checks capture() and snapshot()
for equality, and failed first on the old-start-state path.

A mapper correction of 16+ was lost on a "DiskDude!" dump (gamedb,
header.rs). v2.9.8's dirty-tail rule masks mapper bits 4-7 when an
iNES 1.0 header has a non-zero byte in 12-15. The two writers that set a
mapper on such a header left that tail in place. The game database wrote
bytes 6-7 and nothing else. For the test's mapper-2 dump corrected to
66, byte 7 already held the needed 'D' nibble, so the writer saw NO
change at all and returned false. With a PAL row, ines1_as_nes2 then
copied the MASKED id into a clean NES 2.0 header, and the correction was
gone for good. The header editor's preserving writer copies the tail
back by design. Both now zero bytes 12-15 when the new mapper is 16 or
more (the gamedb one before the region step). iNES 1.0 reads nothing
else there. Tests:
- a_mapper_correction_survives_a_dirty_tail: with no region and with
  PAL, checking the mapper and the region;
- preserving_mapper_edit_reads_back_over_a_dirty_tail: an edit to 66
  reads back, and an edit to 3 keeps the tail.
Both failed first.

$4017 inhibit (frame_counter.rs). v2.9.8 moved the flag clear onto the
write (for Nintendo World Championships 1990) and left irq_inhibit to the
3-4 cycle timer reset. A write 1-3 cycles before step 29828 therefore let
the OLD sequence raise the IRQ again inside the delay. That is an
interrupt the program had just inhibited, and a model that contradicts
itself. The write now sets irq_inhibit too. Evidence, stated plainly: no
ROM reaches this window. The ROM suites pass under both models (checked
by running them on the change: AccuracyCoin, apu_test, blargg 2005,
apu_reset, PAL, the coincidence test and nes_blargg). The change rests on
the wiki, which ties the flag clear to the inhibit bit, not to the reset.
docs/apu-2a03.md is updated, and the line plan adds it to the sibling's
v2.9.9 parity list. Test: write_4017_inhibit_holds_through_the_reset_
delay (lead 1-3, aligned and not). Deleting the new line makes it fail.

Netplay half-sync (message.rs). Protocol 5 kept v4's Sync magic
"RNES", and a v4 decoder reads the magic and the ROM hash and ignores
the rest. So a v4 peer could take v5's longer Sync as a match and mark
the session synced, while the v5 side refused v4's short reply and timed
out. SYNC_MAGIC is now "RNE5" (0x524E_4535), which every version
compares, and a Sync payload must be exactly 68 bytes. Test:
a_v5_sync_is_not_a_v4_sync. The netplay suite passes (92 unit tests plus
the loopback integration tests).

Android netplay emphasis (rustynes-mobile, Netplay.kt). The netplay path
turns the index framebuffer into pixels with its own LUT. That was a
Kotlin copy of the base palette and of the old 13/16 emphasis rule,
which the core replaced with the documented model in this release. The
copy is gone. A new UniFFI function default_palette_argb() returns the
core's build_rgba_lut(Composite2C02) packed 0xAARRGGBB as i32 (Kotlin
Int), and NetplayPalette.ARGB reads it lazily. A Rust test pins every
entry to the core LUT. The Kotlin was NOT compiled on this host. CI's
NDK + UniFFI and Gradle jobs compile it. iOS netplay ignores emphasis
entirely, a documented carry-over and not a stale copy, so it is
unchanged.

Smaller:
- ApuSnapshotError::TrailingBytes(n) for a too-long blob, which used to
  report "truncated at offset N". The enum is already non_exhaustive;
  a mutation back to Truncated fails the test.
- The movie format header said 3 bytes per frame; format 3 writes 5.
- Nes::restore's doc gave a reason, the header-inclusive hash tag, that
  v2.9.8 removed. The doc now says so and keeps the reason that still
  holds.
- m085_vrc7's save_state comment said v1 still loads; since v2.9.8 only
  v3 and v4 do.

CHANGELOG [2.9.8] and the v2.9.8 release notes carry the user-visible
fixes.

Verified on this tree:
- fmt; clippy -D warnings for the workspace, scripting+hd-pack,
  retroachievements, full, and both wasm32 builds; rustdoc -D warnings;
  the no_std thumbv7em build;
- cargo test --release --workspace --features test-roms: 3,145 passed,
  0 failed, 14 ignored (3,139 plus the six new tests);
- markdownlint on the changed documents.
Not verified here: the Kotlin compile (CI) and the commercial suite
(re-run separately for the records).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CodeRabbit's review of slice #581 (the frontend, platforms, harness, CI
and docs half of this branch) left 16 threads and 3 outside-diff
comments. Six cite code that lives in the OTHER slice (#580), so this
review read main's versions of it. They are answered with #579's code
and need no change:
- AGENTS.md's format numbers. The code has FORMAT_VERSION 3,
  MIN_FORMAT_VERSION 3, BUS_SECTION_VERSION 2, MOVIE_FORMAT_VERSION 3.
- The header editor's "unmodelled" fields. Header models all six.
- movie_ui's region/board check and its options-before-seek. Both are
  done in seek_to_start.
- The mapper 78 default. The default is CosmoCarrier, pinned by
  mapper_78_ines_header_selects_wiring_by_alt_nametables_bit.
The rest were real. Each behaviour fix below had a test that failed
first.

The history viewer exported across an options change. A movie carries
one set of options, and export_from took the start anchor's while
including every later frame. A Four Score or overclock toggled mid-
session therefore replayed under the wrong machine. record_frame now
captures HardwareOptions every frame and places an anchor on the frame
they change, whatever the anchor period. export_from ends the clip at
the first later anchor whose options differ. The capture allocates only
for Game Genie codes, and only while the viewer records. Test
an_export_stops_at_an_options_change: period 1,000, toggle at frame 25;
the export from 0 has 25 frames, the export from 30 starts at the change
with the new options.

TAStudio replayed and exported differently from its movie.
- seek and record_frame applied p1/p2 only, while movie playback uses
  FrameInput::apply_to (four ports). A four-player log played
  differently in the editor than in its export. Both now use apply_to.
- to_movie captured options at export time. Every cached state descends
  from frame 0, so the editor now captures start_options with the
  frame-0 state and exports those.
Tests: seek_and_record_drive_all_four_ports and
an_export_carries_the_frame_zero_options. The .rnmproj project format
still stores players 1-2 only (its documented 3-byte record); that is
unchanged and stated in the reply.

Power Cycle read the .pal file under the emu lock. configure_console
calls palette_for_config, which reads a .pal from disk on native.
do_power_cycle called it inside self.emu.lock(), twice for a cabinet. A
stalled drive or network share would stall the emulation thread on the
mutex. configure_console now wraps configure_console_with_palette, and
do_power_cycle resolves the palette once, before the lock. The
source-shape test also asserts that order and the single read. Moving
the read back under the lock fails it (checked).

agy round 2 on #579: the desktop's save-state and movie errors never
reached the screen. The menu dispatch set "State loaded" whatever
handle_load_state did, and the F4 hotkey set no status at all. A state
refused for being from v2.9.7 or earlier, which the release notes say
is "refused with a message saying so", therefore looked like a
successful load, and its reason went only to stderr. The same was true
of an old movie's "re-record" refusal (Movie::deserialize in
handle_movie_play_toggle) and of a refused seek. handle_save_state and
handle_load_state now return their outcome. One helper,
report_state_result, puts it on the status line at all six native sites
(menu, slot menu, hotkeys, script control, save-states panel). Script
saves report failures only, so a per-frame script cannot bury the
status line. The movie toggle shows its parse and seek refusals.
handle_movie_play_toggle takes &mut self for it. The browser's saves are
asynchronous and still only log, which is unchanged and stated in the
CHANGELOG. There is no automated test for the toasts: App needs a window
and a GPU; clippy for every feature set checks every call site.

Docs that described the code wrongly:
- ADR 0044 and the CHANGELOG said power-on recording keeps the
  overclock. start_recording_power_on zeroes the extra scanlines before
  capturing the options, so recording runs at stock timing and playback
  applies whatever overclock a movie stores.
- pixel-provenance.md still said the verifier assumes a default profile
  and the format has none. It now matches main.rs's notice: options
  applied, the header used as found.
- performance.md's "row §3.1 C above" points at the v2.9.1 table below.
- accuracy-ledger.md: 4 + 1 + 5 + 2 + 1 = 13 ignored tests, not 16.
- libretro-disposition.md's libnx row: CLOSED at v2.9.8 (see L-3.2).
- to-dos/plans/README.md: v3.0.0 is the API major with an RC core under
  ADR 0043, and the hardware moves to v3.x.
- CHANGELOG line 121 rewrapped.

coverage.py:
- load_tiers warns once when the parsed tiers differ from the fallback
  sets. An arm the regex cannot read (a guard, a range, `_ =>`) leaves
  a tier short but non-empty, which is accepted silently. Counting
  `Some(MapperTier::...)` occurrences cannot be the check: 14 of 19 are
  in tier.rs's tests.
- categorize's dry run now applies the duplicate test, so it predicts
  the FLAGGED list and exit code of a real run.

Commercial suite measured on 1a66470: 3,364 passed, 0 failed, 19
ignored (3,358 + the six tests of the previous commit). This confirms
STATUS.md's 3,358 baseline; the release notes' 3,357 was the wrong one.
The counts are recorded after the final run of this commit's tree.

Verified so far: the affected frontend tests (47 passed); the three new
tests failed first; the power-cycle ordering mutation is caught;
categorize --dry-run runs; ruff and markdownlint are clean. The full
gates follow before the counts commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

Answers to Antigravity round 2 (on 1a66470):

Blocking: SemVer. Declined, as in round 1. The maintainer chose to land v3.0.0's breaks in v2.9.8 and keep the v2.9.x number. ADR 0042 and ADR 0043 record the decision, and v3.0.0's notes must restate every break. The crates are not published to crates.io (checked).

Blocking: saves are not found after the identity change. Declined. This is a maintainer decision, taken with exactly this consequence in view: the maintainer chose header-excluded identity over a fallback, saying they did not mind breaking existing saves. That way a corrected header can never orphan a save again. The release notes open with it ("Older saves are not found for cartridge games") and the CHANGELOG lists it under the breaking changes. A rename-on-miss fallback would keep the old whole-file hash alive as a second identity indefinitely, which is the coupling this change removes.

Suggestion: surface refused states and movies in the UI. Taken, and it was worse than you described. Fixed in 2fa43b8:

  • After F4 or a menu load, the desktop showed "State loaded" whatever happened, or nothing at all for the hotkey. A v2.9.7 state therefore looked like it had loaded, and the reason went only to stderr.
  • The same was true of an old movie's "re-record" refusal.
  • handle_save_state and handle_load_state now return their outcome, and one helper puts it on the status line at all six native sites.
  • The movie toggle shows its parse and seek refusals.
  • The browser's async saves still only log.

Nitpick: the .gitignore comment. Kept. That file comments its non-obvious entries throughout, and the reason, a worktree's tests/roms/external symlink that a trailing slash does not match, belongs beside the pattern rather than only in history.

Nitpick: the Famicom console model in the user manual. It is documented in README.md ("for the few carts that depend on it"), in docs/frontend.md (Settings > Emulation > Accuracy, with the config key and when it takes effect) and in docs/ppu-2c02.md (the model and its limits).

@doublegate

Copy link
Copy Markdown
Owner Author

Answers to Antigravity round 3 (on 2fa43b8):

  • Blocking: SemVer. Declined for the third time, for the reasons given in rounds 1 and 2: it is the maintainer's decision, and ADR 0042 and ADR 0043 record it. Under the review stopping rule, a repeated, already-answered finding does not hold the merge.
  • famicom_console.rs, let _ = create_dir_all(dir). Not silent. This is an opt-in diagnostic dump, used only when RUSTYNES_FAMICOM_DUMP is set, and the two write_png(...).expect("png") calls straight after it panic if the directory could not be created. It fails loudly one line later, with the path in the error. No change.
  • movie_interop.rs, let _ = writeln!(out, ...). out is a String (line 323), and fmt::Write for String cannot fail, so there is no failure path to swallow. No change.

Measured on 2fa43b8, the tree this commit documents, which differs from
it only by these four count lines:
- cargo test --release --workspace --features test-roms --no-fail-fast:
  3,148 passed, 0 failed, 14 ignored (was 3,139; +9 tests from the two
  review rounds: six on 1a66470, three on 2fa43b8);
- with commercial-roms too: 3,367 passed, 0 failed, 19 ignored.

Corrects the release notes, which said 3,357. CodeRabbit flagged that it
disagreed with STATUS.md and the CHANGELOG (3,358). The run on 1a66470
measured 3,364 = 3,358 + 6, so 3,358 was the right baseline and 3,357
the wrong figure.

Also verified on 2fa43b8: fmt; clippy for the workspace, scripting +
hd-pack, retroachievements, full, and both wasm32 builds; rustdoc
-D warnings; the no_std build; the four release audits (anchor,
state prose, notes render, contribution checklist); markdownlint. #579's
CI is green on 2fa43b8, including test-roms, the Kotlin unit tests and
the Gradle bundle, which also compiles the Android netplay palette change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@doublegate

Copy link
Copy Markdown
Owner Author

Answers to Antigravity round 4 (on 6ee34b1). Nothing new here blocks, so there is no push:

  • Both blockers (SemVer, and the save identity and format breaks) repeat rounds 1-3. They are maintainer decisions, and are declined with the reasons given there.
  • FrameInput::port / port_mut panic above 3. Kept. No external value reaches them: no call site outside movie.rs passes a computed index, and the movie decoder builds frames field by field. They mirror Nes::set_buttons, which panics on the same range, and both document it under # Panics. A port index is a programming constant, not input; an Option would push an impossible branch onto every caller.
  • writeln! into a String. As in round 3: fmt::Write for String cannot fail, so no error is swallowed.
  • PR title format. This follows the repository's release-PR convention (v2.9.7 "Tandem" -- ... v2.9.7 "Tandem" -- web and mobile parity, full release binaries, and a PPU A12 fix found by real games #577, v2.9.6 "Roster" -- ... v2.9.6 "Roster" -- 17 mapper families, GTROM Curated with flash saves, MMC3 submappers fixed #576). The commits inside follow Conventional Commits.

@doublegate
doublegate merged commit 0bd814d into main Oct 3, 2026
39 checks passed
@doublegate
doublegate deleted the feat/v2.9.8-cadence branch October 3, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants