diff --git a/.github/release-notes/v3.2.0.md b/.github/release-notes/v3.2.0.md
new file mode 100644
index 000000000..8f2861bfe
--- /dev/null
+++ b/.github/release-notes/v3.2.0.md
@@ -0,0 +1,63 @@
+# RustyNES v3.2.0 — "Confluence"
+
+Every v3.2.0 branch, both repositories, brought into one release. It adds 17 mapper families (191 to 208), among them the Bandai Datach with its barcode reader, and five more families are Curated on local-dump evidence. Five emulation defects are fixed: the VRC4's IRQ mode was read inverted, VRC2 mirroring had settings the chip does not have, *Fire Hawk* lost its single-screen mirroring on an iNES 1.0 header, mapper 290 did not boot, and a 24C02 EEPROM lost its word address across a START. An AI-made remediation branch was audited change by change before anything was kept: its byte-identical speed-ups are kept, and measured against the tree it joined: the APU mixes 2-6% faster and an MMC3 read about 9% faster, while its larger claims did not hold and two cartridge reads got slower for a reason not yet found; a 47% audio slowdown it introduced was found and removed; four of the five features it broke are fixed and kept, and Android audio is back on its blocking write, with the non-blocking version a ticket. Netplay moves to protocol 8, libretro save states shrink from 260 KB to about 16 KB, and new save files are owner-only. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line and custom palettes. **Movies and netplay from v3.1.x are refused, and so are v3.1.x save states of Bandai FCG games.**
+
+## Breaking changes
+
+- **Movies and netplay from v3.1.x are refused**: `EMULATION_EPOCH` rises from 3 to 4, because the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes change what the bus sees. Save states are not affected by the epoch.
+- **v3.1.x save states of Bandai FCG games are refused** (mappers 16, 153, 157 and 159): their state now carries each serial EEPROM's protocol position, so a state taken mid-transaction resumes it instead of leaving the chip idle, which could make rewind, run-ahead and netplay rollback diverge. Other boards' states are unchanged.
+- **Netplay protocol 8 (`"RNE8"`)**: lost inputs are resent as one datagram per run of frames. A v3.1.x peer is refused as another emulator version.
+- **The `Mapper` trait requires `ppu_peek`** (a side-effect-free PPU read for the debugger), so a mapper implemented outside this repository must add it. `cpu_peek`, `may_unmap_prg_rom`, `is_save_data_dirty`, `clear_save_data_dirty` and three barcode-reader methods are added with defaults.
+
+## Fixed
+
+- **The VRC4's IRQ runs in the mode the game asks for** (mappers 21, 23 and 25): bit 2 of the IRQ control register was read inverted, so a raster split got a counter about 114 times too fast.
+- **VRC2 mirroring has two settings, not four**, and mappers 23 and 25 with NES 2.0 submapper 3 are built as the VRC2.
+- ***Fire Hawk* gets its single-screen mirroring from an iNES 1.0 header** (mapper 71, submapper 0, at the first `$9000` write).
+- **Mapper 290 (*Asder 20-in-1*) boots**, and **a 24C02 EEPROM keeps its word address across a START** (mappers 16 and 157).
+- **FDS disk games keep their saves on Android and iOS**, and **the Android screen stays on during play**.
+
+## Added
+
+- **Mapper 157, the Bandai Datach Joint ROM System**, with a barcode reader for the games that read cards.
+- **16 more families**: the MMC3-derived 114, 116, 165, 182, 197 and 215; FFE 6, 8 and 17; Tengen 158; TXC 172 and 173; Taito 207; the 235 multicarts and Waixing 252 (all Curated), and the 230 multicart (BestEffort). Mappers 47, 121, 154, 191 and 243 move to Curated. The split is now 51 Core, 130 Curated and 27 BestEffort: 181 of 208 families are accuracy-gated.
+- **Famicom-only carts boot as a Famicom in the coverage sweep**, so ten dumps listed as blank now draw.
+
+## Changed
+
+- **Libretro save states are about 16 KB instead of 260 KB**: the framebuffer is no longer stored, and a full-size state still loads.
+- **New save files are owner-only on Linux and macOS** (`0o600`).
+- **rustls 0.23.46**, for four TLS advisories published during the release (RUSTSEC-2026-0346 to 0349), in the netplay WebSocket and HTTP clients.
+- **The APU mixes 2-6% faster with expansion audio and 2-3% faster without**, byte-identical, in two independent A/B runs.
+- **An MMC3 cartridge read is about 9% faster, and run-ahead on an MMC3 game about 5%**, from cached bank offsets; byte-identical, two independent runs. Whole-frame time and the PPU do not change measurably. MMC5 and Sunsoft FME-7 reads measure 4.7% and 6% slower although neither read function changed; the cause is not established (`docs/performance.md`, "v3.2.0 import").
+
+## The AI remediation branch, audited
+
+An unpushed agent branch arrived with 61 audit findings already applied. Nothing was merged as is. Every change was read against the code it replaced, and each defect found has a test that fails without the fix. Kept: 20 as written, 14 corrected. Reverted: 4, the audio ring write behind the 47% slowdown, an MMC5 IRQ change under which the IRQ stopped firing in real play, and two unmeasured libretro rewrites; an FDS audio formula change inside a kept finding was reverted too, because it moved every FDS game's audio. Rejected: 10, including six of its seven FPGA findings, which did not hold when re-derived in simulation and synthesis. The ledger is `docs/audits/v3.2.0-round2-audit-disposition.md`; the report's "certified" and "100% bit-accurate" claims are recorded as unverified, and two of them were tested and are false.
+
+## The MiSTer core
+
+- **Release candidate, not hardware-verified.** No hardware has run either bitstream.
+- **Sixteen more boards**: UN1ROM, Nichibutsu UNROM, CPROM, J87, CNROM 185, GxROM, Jaleco JF-11/14, Color Dreams, NINA-03/06, Camerica 71 (including *Fire Hawk*) and 232, mapper 34, Namco 108, TxSROM, TQROM and MMC1 SUROM (off-die). Each is gated against the emulator, and thirteen against a commercial game's frame (SUROM's off-die only).
+- **The Four Score, the Arkanoid controller and Game Genie cheats**, gated against goldens exported with the same device.
+- **More than eight sprites per line** (on-die build) and **custom `.pal` palettes** with the emulator's emphasis model.
+- **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems reproduce byte for byte.
+- **The co-simulation ladder**: 259 passed, 0 failed, 3 expected failures on-die and 260 / 0 / 5 off-die (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each one run of a frozen tree, nothing skipped. The new expected failures are named and graded both ways: two commercial frames still differ from the emulator's, by one pixel (a sprite at X=255 on its last row) and by three scanlines of a mid-frame raster change, neither yet attributed; off-die, two more are a CHR-RAM read-back defect that predates this release (`T-MISTER-OFFDIE-CHRRAM`).
+- **New bitstreams at fitter seed 4**, chosen from eight seeds swept on build date 261014 by the rule both builds share: the on-die build's binding margin chooses, and the off-die build must close at the chosen seed. Seeds 5 and 6 lead on-die but do not close off-die; seeds 1 (v3.1.0's) and 4 tie next, and only 4 closes off-die. Each was compiled twice to the same bytes:
+ - on-die `RustyNES_MiSTer-v3.2.0.rbf`, md5 `e24820de2e302d3f6912524b3c97a2be` (+0.213 ns setup, +0.079 ns hold);
+ - off-die `RustyNES_MiSTer-v3.2.0-offdie.rbf`, md5 `c33ae2b14e5b8e07a68b4335c728ca03` (+0.038 / +0.080 ns). The +0.038 ns path is inside the FPGA (the SDRAM controller's read data into the CPU), so board delay does not consume it; the SDRAM read capture itself has +0.453 ns setup and +1.180 ns hold over all four timing corners, assuming zero board delay.
+
+## Verification
+
+- `--features test-roms,commercial-roms`: 3,669 passed, 0 failed, 16 ignored, across 176 test binaries.
+- The local commercial suites: `external_real_games` 60/0, `external_extended` 169/0, `external_coverage` 6/0 over every staged dump, 25 of them baselined new in this release.
+- Every clippy, rustdoc, `no_std` and wasm invocation CI runs (23), clean.
+- AccuracyCoin 146/146, nestest 0-diff, the epoch fingerprint at epoch 4, now recorded as the released epoch.
+
+## Install
+
+- Download the pre-built binaries for Linux, macOS, and Windows below.
+- The MiSTer core bitstreams are attached below, as a release candidate, not hardware-verified. The on-die build is `RustyNES_MiSTer-v3.2.0.rbf`, also attached under its datecoded name, and the off-die build is `RustyNES_MiSTer-v3.2.0-offdie.rbf`.
+- The WebAssembly build is live at [doublegate.github.io/RustyNES](https://doublegate.github.io/RustyNES/).
+- The RetroArch core is in RetroArch's Online Updater on the platforms the libretro buildbot publishes to.
+- Licensed under GPL-3.0-or-later.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5c0ac339a..7506c9065 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -892,10 +892,17 @@ jobs:
# build script panics before a single Rust line is compiled. The NDK path
# prefix matches `android.yml`'s (`$ANDROID_NDK_LATEST_HOME`, then
# `toolchains/llvm/prebuilt/linux-x86_64/...`).
+ #
+ # The --target carries an API level since the runner image moved to NDK
+ # 30 (October 2026): its sys/cdefs.h fails with "Unversioned target
+ # triples are not supported!" for plain `aarch64-linux-android`, which
+ # is the triple bindgen hands clang. 26 is the Android app's minSdk and
+ # the `--platform 26` cargo-ndk builds with, so the bindings see the
+ # same headers the shipped core is built against.
- name: Point bindgen at the NDK sysroot
if: matrix.target == 'aarch64-linux-android'
run: |
- echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--sysroot=$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "$GITHUB_ENV"
+ echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android26 --sysroot=$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "$GITHUB_ENV"
# Provision the aarch64 glibc headers for bindgen (see the matrix note).
# `libc6-dev-arm64-cross` lands the aarch64 glibc headers under
# `/usr/aarch64-linux-gnu/include`; `--sysroot` sends clang there instead of
@@ -1042,7 +1049,8 @@ jobs:
# v2.9.4 — the eight fuzz targets under fuzz/ were built by nobody: no
# workflow mentioned them, so a target could stop compiling and nothing would
- # say so. This builds all eight and runs each for a FIXED number of inputs from
+ # say so. This builds them all (nine since v3.2.0's `movie_import`, the five
+ # foreign-movie importers) and runs each for a FIXED number of inputs from
# a FIXED seed, so a run is reproducible and a failure is a real crash, not a
# time-box artifact. It starts from an empty corpus (fuzz/corpus is local).
# Measured on the maintainer's machine with the local corpus: ~70 s for all
@@ -1051,7 +1059,7 @@ jobs:
# libFuzzer needs nightly; the fuzz crate is its own workspace (fuzz/Cargo.toml),
# so the project pin in rust-toolchain.toml does not apply to it.
fuzz:
- name: fuzz (8 targets, fixed seed)
+ name: fuzz (9 targets, fixed seed)
needs: [changes, setup]
if: ${{ needs.changes.outputs.code == 'true' && needs.setup.outputs.heavy == 'true' }}
runs-on: ubuntu-26.04
@@ -1065,7 +1073,7 @@ jobs:
toolchain: nightly
apt: "false"
cache-key: fuzz
- - uses: taiki-e/install-action@v2.87.26
+ - uses: taiki-e/install-action@v2.87.27
with:
tool: cargo-fuzz
- name: Build and run every fuzz target
@@ -1082,7 +1090,7 @@ jobs:
cargo +nightly fuzz build -O --target x86_64-unknown-linux-gnu
targets=$(cargo +nightly fuzz list)
n=$(printf '%s\n' "$targets" | grep -c .)
- [ "$n" -ge 8 ] || { echo "::error::expected at least 8 fuzz targets, found $n"; exit 1; }
+ [ "$n" -ge 9 ] || { echo "::error::expected at least 9 fuzz targets, found $n"; exit 1; }
# Run EVERY target even after one crashes, so a single run reports
# all of them rather than the first (agy on #574).
failed=""
@@ -1129,7 +1137,7 @@ jobs:
with:
components: llvm-tools-preview
cache-key: coverage
- - uses: taiki-e/install-action@v2.87.26
+ - uses: taiki-e/install-action@v2.87.27
with:
tool: cargo-llvm-cov
- run: cargo llvm-cov --workspace --lib --summary-only --fail-under-lines 70
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 42dc496c6..3159fcf6f 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -76,7 +76,7 @@ jobs:
# Reason 2 was an ACTIVE constraint until the v1.3.0 pin bump (1.86 vs
# cargo-audit's 1.88); it is slack today and can bind again after an MSRV
# change in either direction, so it is kept rather than deleted as history.
- - uses: taiki-e/install-action@v2.87.26
+ - uses: taiki-e/install-action@v2.87.27
with:
tool: cargo-audit
- run: cargo audit
@@ -92,7 +92,7 @@ jobs:
# Prebuilt binary for the same two reasons as the audit job above: it is
# much faster than compiling cargo-deny, and it keeps the security gate
# independent of this repo's rustc pin. Policy lives in `deny.toml`.
- - uses: taiki-e/install-action@v2.87.26
+ - uses: taiki-e/install-action@v2.87.27
with:
tool: cargo-deny
- run: cargo deny check
diff --git a/.gitignore b/.gitignore
index 3385cc027..e4662ca2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -352,6 +352,10 @@ WARP.md
# Local scratch / salvage working dir (data/docs/logs pulled from /tmp triage); not
# part of the repo.
/salvaged/
+# v3.2.0: an AI agent's /tmp-salvage scratch from the agy/Codex remediation
+# branch (session transcripts, a /tmp listing, the author's address). Kept in the
+# gitignored salvaged/ above; it must never be committed under this name either.
+/salvaged_tmp/
# NOTE: BestEffort boot-smoke ROMs (homebrew/unlicensed/multicart) are no longer
# a separate tree — they live under the already-ignored tests/roms/external/
diff --git a/AGENTS.md b/AGENTS.md
index e4d74e11a..11fcbab4f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,12 +44,12 @@ Enforcement lives alongside the prose: `/ref-proj/` is gitignored/`.dockerignore
RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust. The accuracy bar is Mesen2 / higan / ares: tight lockstep scheduling at PPU-dot resolution on a master-clock-precise timebase, sub-instruction PPU events visible to subsequent CPU code, and a lookup-table non-linear audio mixer with band-limited synthesis. The frontend is pure Rust (`winit` + `wgpu` + `cpal` + `egui`).
-**Current release: v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). **No hardware has run any MiSTer bitstream**: hardware verification moves to a later v3.x release (ADR 0043 supersedes [ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)'s hardware-verified v3.0.0), and the SDRAM pin constraints stay provisional until the SuperStation One's memory is read. Suite counts, mapper matrix and per-release detail live in `docs/STATUS.md`, `CHANGELOG.md` and the GitHub releases; they are deliberately not duplicated here. The earlier release-by-release narrative and other condensed paragraphs are archived verbatim in `docs/history/AGENTS-archive.md`.
+**Current release: v3.2.0 "Confluence"** (2026-10-14) — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)). **No hardware has run any MiSTer bitstream**: hardware verification moves to a later v3.x release (ADR 0043 supersedes [ADR 0041](docs/adr/0041-hardware-release-is-v3.0.0.md)'s hardware-verified v3.0.0), and the SDRAM pin constraints stay provisional until the SuperStation One's memory is read. Suite counts, mapper matrix and per-release detail live in `docs/STATUS.md`, `CHANGELOG.md` and the GitHub releases; they are deliberately not duplicated here. The earlier release-by-release narrative and other condensed paragraphs are archived verbatim in `docs/history/AGENTS-archive.md`.
- **Native Android app** — the **v1.8.0 → v1.8.9 "Android"** train (`crates/rustynes-mobile` UniFFI bridge + `crates/rustynes-android` JNI/NDK host + a Jetpack Compose app, ADR 0024): full on-device emulation, multi-touch + P1–P4 hardware controllers, wgpu `SurfaceView` rendering + the shared WGSL shader stack, save-states / battery SRAM, Lua, RetroAchievements, direct-IP + CGNAT/TURN room-code netplay, a box-art ROM library, and platform polish (adaptive / foldable / TV, Material You, capture / PiP / home-screen widget). Distributed as **GitHub-Releases sideload**; a free store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0025's v2.3.0 date; the `foss`/`play` flavour split remains).
- **Native iOS / iPadOS app** — the **v1.9.0 → v1.9.9 "iOS" TestFlight train** (`crates/rustynes-ios` Metal + CoreAudio shim reusing `rustynes-mobile` verbatim → UniFFI-generated Swift, ADR 0026): a native SwiftUI shell over wgpu→Metal, multi-touch + GameController, the shader stack, TAS / HD-pack / palettes / per-game DB, Lua + RetroAchievements, LAN + room-code netplay, CloudKit save-state sync, accessibility + EN/ES i18n + ReplayKit + Game Center, and the v1.9.9 creator tools (Cheats, a FOSS-gated read-only debugger, a touch TAStudio piano-roll, foreign movie import, a host audio-depth DSP). Ships to **TestFlight**; a free App Store listing is an unversioned later step with no monetization (ADR 0035, which superseded ADR 0027's v2.3.0 date). Mobile ROM loading was iNES / NES 2.0 only until v2.9.7, which adds FDS (host-supplied BIOS), NSF and the Vs. DualSystem cabinet to the bridge (the Swift half uncompiled; run-sheet rows T1-T12). Readiness record: `docs/ios-v1.9.9-readiness.md`.
- **Native Libretro core** — `crates/rustynes-libretro` (builds the `rustynes_libretro` shared library — `.so` / `.dylib` / `.dll` by platform, per the crate `Makefile`) integrates RustyNES into RetroArch (RetroAchievements, dynamic audio sync, deterministic save-state / rollback). Docs in `docs/libretro/`, plan in `to-dos/libretro/`, reference in `ref-docs/RustyNES-Libretro_Core.md`; the crate `Makefile` cross-compiles natively and `docs/libretro/UPSTREAM_SYNC.md` covers the re-fork / upstream-info-file (libretro-super + libretro-docs) workflow.
-- **Mapper breadth → 191 families** (168 at the v1.7.x tag → 172 → 174 with v2.3.4's 176/2, 154 and 243 → 191 with v2.9.6's 17 families, all from the NESdev wiki), Core / Curated / BestEffort behind the CI accuracy-honesty gate (51 / 109 / 31). Battery saves go through `save_data()`, which differs from `sram()` only on the self-flashable GTROM and UNROM 512 boards.
+- **Mapper breadth → 208 families** (168 at the v1.7.x tag → 172 → 174 with v2.3.4's 176/2, 154 and 243 → 191 with v2.9.6's 17 families → 208 with v3.2.0's 17, all from the NESdev wiki), Core / Curated / BestEffort behind the CI accuracy-honesty gate (51 / 130 / 27). Battery saves go through `save_data()`, which differs from `sram()` only on the self-flashable GTROM and UNROM 512 boards.
- **Release automation** — `.github/workflows/release-auto.yml`: when a new version goes final-green on `main`, it auto-tags + publishes the GitHub Release (body from a maintainer-authored `.github/release-notes/vX.Y.Z.md` override, else the CHANGELOG `[X.Y.Z]` section; title codename parsed from the CHANGELOG header) and builds + attaches the desktop binaries by invoking `release.yml` via `workflow_call` (a tag pushed by `GITHUB_TOKEN` can't trigger `on: push: tags`, hence the direct call). The v1.8.0–v1.9.9 GitHub Releases are all published with comprehensive notes + Linux / macOS-aarch64 / Windows binaries.
**The cross-version facts worth keeping here:** RustyNES has broken its formats by design three times. **v2.0.0 "Timebase"** (ADR 0003) bumped the `.rns` save-state and `.rnm` movie format epochs (ADR 0028), so a pre-v2.0.0 slot fails to load with a clear error rather than silently misreading stale bytes. **v2.9.8 "Vanguard"** carried v3.0.0's planned breaks ahead of it (ADR 0042 / 0043 amendments of 2026-10-01; the maintainer kept the v2.9.x number as preparation for v3.0.0). It made three changes:
@@ -214,14 +214,14 @@ what a session needs before it knows what it is doing.
| read when the task touches | file | bullets |
|---|---|---|
| GitHub Actions, workflows, the release ceremony, lint coverage | [`docs/agents/ci-and-release.md`](docs/agents/ci-and-release.md) | 25 |
-| a PR review — the ceremony, where findings hide, which claims recur | [`docs/agents/review-bots.md`](docs/agents/review-bots.md) | 13 |
-| the libretro core, the buildbot, the upstream `.info`, RetroArch | [`docs/agents/libretro.md`](docs/agents/libretro.md) | 17 |
-| the MiSTer sibling — the RTL, Quartus, the rungs, the bitstream | [`docs/agents/mister-cosim.md`](docs/agents/mister-cosim.md) | 33 |
+| a PR review — the ceremony, where findings hide, which claims recur | [`docs/agents/review-bots.md`](docs/agents/review-bots.md) | 14 |
+| the libretro core, the buildbot, the upstream `.info`, RetroArch | [`docs/agents/libretro.md`](docs/agents/libretro.md) | 19 |
+| the MiSTer sibling — the RTL, Quartus, the rungs, the bitstream | [`docs/agents/mister-cosim.md`](docs/agents/mister-cosim.md) | 35 |
| accuracy work — AccuracyCoin, blargg, sub-tests, goldens, the PPU | [`docs/agents/accuracy-oracle.md`](docs/agents/accuracy-oracle.md) | 23 |
| reading a result — what it does and does not prove | [`docs/agents/measurement-discipline.md`](docs/agents/measurement-discipline.md) | 22 |
-| the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 19 |
-| a dependency bump, or why one is blocked | [`docs/agents/dependencies.md`](docs/agents/dependencies.md) | 3 |
-| a performance claim, or a debugger panel that outlives its `Nes` | [`docs/agents/perf-and-panels.md`](docs/agents/perf-and-panels.md) | 7 |
+| the shell, `pre-commit`, `gh`, `/tmp`, long-running jobs | [`docs/agents/tooling-traps.md`](docs/agents/tooling-traps.md) | 25 |
+| a dependency bump, or why one is blocked | [`docs/agents/dependencies.md`](docs/agents/dependencies.md) | 5 |
+| a performance claim, or a debugger panel that outlives its `Nes` | [`docs/agents/perf-and-panels.md`](docs/agents/perf-and-panels.md) | 8 |
**Read the file, not a summary of it.** Each bullet carries its own evidence —
the command that was run, the number it returned, the mutation that caught it —
@@ -236,7 +236,7 @@ that is a reason to add a tenth — not a reason to grow this section back.
- `ref-docs/` is immutable. Research updates go in dated supplemental files.
- ADRs go in `docs/adr/` (Michael Nygard format).
- `rustynes-core` re-exports the public types from the chip crates; downstream consumers (`rustynes-frontend`, `rustynes-test-harness`) should depend on `rustynes-core` rather than the chip crates directly.
-- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v3.1.0 "Bellwether"** (2026-10-08). **Never claim any version *later* than v3.1.0 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and broke byte-identity and save-state compatibility, by design (v2.9.8 later broke save, movie and API compatibility again, ahead of v3.0.0). The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not.
+- When relabeling old engine "v2.x" narrative for users, present it as upstream lineage/history — **never as a current RustyNES release version.** The current release is **v3.2.0 "Confluence"** (2026-10-14). **Never claim any version *later* than v3.2.0 is released.** Two distinct "v2.0"s exist and must not be conflated: the **engine-lineage v2.0** master-clock work shipped as the **v1.0.0** production core (2026-06-13) and was the only scheduler through v1.10.0; RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is a different milestone that REPLACES that dot-lockstep scheduler with the one-clock, every-cycle-bus-access model (ADR 0002 / 0028 / 0029) and broke byte-identity and save-state compatibility, by design (v2.9.8 later broke save, movie and API compatibility again, ahead of v3.0.0). The per-release narrative that used to be inlined here is in `CHANGELOG.md`, the per-release notes under `.github/release-notes/`, and the published GitHub releases — three places that are maintained, against one copy here that was not.
- **Forward plans + roadmap live in `to-dos/`.** `to-dos/ROADMAP.md` (updated in #129) is the planning entry point and frames the release line + "the path to v2.0.0 and beyond"; `to-dos/plans/` holds the per-release plan docs (through `v1.7.0-forge-plan.md` on `main`, plus the staged-forward `v1.8.0-android-plan.md` / `v1.9.0-ios-plan.md` / `v2.0.0-master-clock-plan.md`) + the `to-dos/plans/engine-lineage/` history archive + a `to-dos/plans/research/` reference-mining archive.
- The v1.0.0 release + GitHub Pages/CI + post-release record is in `docs/v1.0.0-synthesis-handoff-2026-06-13.md` — read it before touching CI, Pages, or release tooling. Full per-release history is in `CHANGELOG.md`.
- **Markdownlint is a CI gate** (pre-commit, pinned `markdownlint-cli v0.49.1`). The pin was v0.39.0 until the v2.6.3 dependency refresh, held because the newer local binary reported rules the pin lacked — chiefly **MD060** (`table-column-style`), which was therefore NOT gated. That is now measured and resolved: MD060's inferred default reads this corpus as style `compact` and reports **1,936 findings across 122 files** and nothing else, so `.markdownlint.json` pins `MD060` to the style actually in use (`leading_and_trailing`), which measures **zero** and rewrites no document. It IS a gate now. Still verify with `pre-commit run markdownlint --all-files` rather than the bare binary — the pin and the local build can drift apart again. `.markdownlint.json` also keeps `MD013`/`MD033`/`MD041` disabled by design (long technical tables, the README HTML banner/``, the HTML-led README). `.markdownlintignore` exempts `ref-docs/`, `ref-proj/` (the reference-emulator clone, now removed from disk but kept in the ignore lists as a firewall guard so it can never re-enter the tree — see the MOST IMPORTANT RULE section above), the vendored `tricnes/` + upstream READMEs, and the frozen `docs/archive/` + `to-dos/archive/` trees — don't lint or reformat those.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7a9984cee..15f7eeba5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -2,7 +2,7 @@
**Document Version:** 2.1.0
**Last Updated:** 2026-10-07
-**Applies to:** RustyNES v3.1.0 (the scheduling model is v2.0.0 "Timebase" onward)
+**Applies to:** RustyNES v3.2.0 (the scheduling model is v2.0.0 "Timebase" onward)
This document fixes the high-level architecture of RustyNES. The per-subsystem specs under `docs/` (`cpu-6502.md`, `ppu-2c02.md`, `apu-2a03.md`, `mappers.md`, `scheduler.md`) take these decisions as given and elaborate one chip each. After reading this you should know the workspace shape, the scheduling model, the public boundary, and the load-bearing invariants. The canonical, always-current architecture spec is [`docs/architecture.md`](docs/architecture.md); this file is the top-level companion.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 96e49f10a..ecc74ae45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,236 @@ cycle-accurate core later replaced.
## [Unreleased]
+## [3.2.0] - 2026-10-14 - "Confluence" (every v3.2.0 branch in both repositories brought into one release: 17 mapper families, the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core)
+
+### Breaking
+
+- **Movies and netplay from v3.1.x are refused.** `EMULATION_EPOCH` rises
+ from 3 to 4: the mapper 290, 24C02, VRC4 IRQ, VRC2 and *Fire Hawk* fixes
+ below change what the bus sees. A v3.1.x peer or movie is refused as another emulator
+ version, naming its epoch. Save states are not affected by the epoch.
+- **v3.1.x save states of Bandai FCG games are refused** (mappers 16, 153,
+ 157 and 159). Their state now carries each serial EEPROM's protocol
+ position as well as its contents, so a state taken in the middle of a
+ save-chip transaction resumes it; before, a restore left the chip idle,
+ and rewind, run-ahead and netplay rollback could diverge from a straight
+ run. Every other board's save states are unchanged.
+- **Netplay protocol 8 (`"RNE8"`).** Lost inputs are resent as one datagram
+ per run of frames instead of one per frame. A v3.1.x peer cannot read that
+ message, so its `"RNE7"` is refused as another emulator version (its epoch
+ would refuse it anyway).
+- **The `Mapper` trait requires `ppu_peek`**, a side-effect-free PPU read the
+ debugger uses on every board, with no default (a default would show a
+ board that forgot it as a blank pattern table), so an implementation
+ outside this repository must add it. Seven methods are added with defaults
+ that keep the old behaviour: `cpu_peek` (the debugger's CPU read; the FFE
+ boards override it so a peek cannot retire their trainer boot),
+ `may_unmap_prg_rom`, `is_save_data_dirty`, `clear_save_data_dirty` and
+ the Datach's `has_barcode_reader`, `scan_barcode` and
+ `barcode_scan_in_progress`.
+
+### Added
+
+- **Mapper 157, the Bandai Datach Joint ROM System, with its barcode
+ reader.** Six games boot. *Dragon Ball Z* and *SD Gundam Wars* read a
+ scanned card, entered as a 13- or 8-digit JAN/EAN code from Emulation >
+ Scan Barcode... (refused during movie recording or playback and netplay).
+ The reader's polarity, idle level and speed are not documented anywhere;
+ they were measured against the games.
+- **Six MMC3-derived families**: 114 and 182 (SuperGame / Hosenkan), 215
+ (Sugar Softec), 197, 165 (the Chinese *Fire Emblem* ports) and 116
+ (SOMARI-P, which switches between VRC2, MMC3 and MMC1). All Curated, each
+ booted from real dumps.
+- **Ten more families, each written from its NESdev page**: 6, 8 and 17 (Front
+ Fareast copier-disk games on the Super Magic Card), 158 (Tengen's RAMBO-1
+ with CHR A17 on the nametable line, *Alien Syndrome*), 172 and 173 (TXC
+ JV001 / 05-00002-010), 207 (Taito X1-005 with the same rewiring, *Fudou
+ Myouou Den*), 235 (the *Golden Game* address-latch multicarts) and 252
+ (Waixing's VRC4 clone, *San Guo Zhi*) are Curated; 230 (the 22-in-1 whose
+ RESET button switches chips) is BestEffort, because its page leaves a
+ choice the boot does not settle (ADR 0011 amendment).
+- **The golden exporter takes devices and display options**:
+ `nes_golden_export` gains `--four-score`, `--vaus` and `--genie` for the
+ MiSTer core's input gates, and `--sprite-limit-off` and `--fb-every-frame`
+ for its sprite-limit gate. A run without them writes the same artifacts and
+ manifests as before.
+- **The coverage sweep boots Famicom-only carts as a Famicom.** Ten dumps
+ listed as blank since v2.9.6 write the PPU during the front-loading NES's
+ power-on warm-up, which ignores those writes; on the Famicom model they
+ draw. The frontend still does not pick the model from the ROM: choose
+ Famicom in Settings for *Sansuu 1-3*, *Final Combat*, *Digital Dragon* and
+ the like.
+
+### Changed
+
+- **Mappers 47, 121, 154, 191 and 243 are Curated**, on local-dump evidence
+ recorded in `docs/compatibility.md` (maintainer decision D23). With the 17
+ families added in this release, the 208 families are 51 Core, 130 Curated
+ and 27 BestEffort (191 families, 51 / 109 / 31, at v3.1.0).
+- **Libretro save states are about 16 KB instead of 260 KB.** The core
+ no longer stores the 240 KiB framebuffer in a state (`retro_serialize`);
+ the next frame repaints it before anything reads it, so run-ahead, rewind
+ and RetroArch netplay behave the same and move far less data. A full-size
+ state, framebuffer included, is still accepted.
+- **New save files are owner-only on Linux and macOS.** A battery save, state,
+ config or recording that does not exist yet is created with mode `0o600`
+ rather than the umask default (usually `0o644`); a file that already exists
+ keeps its mode, so one you widened stays widened.
+
+### Dependencies
+
+- **zip 8 -> 9 (9.0.3)**, Dependabot #601. `ZipFile::name()` now returns a
+ `Result`, failing only for a name that decodes as neither UTF-8 nor CP437;
+ every ROM, HD-pack and `.bk2` lookup skips such an entry, as it already
+ skipped one it could not read. 9.0.1-9.0.3 fix reads past an entry's
+ declared size and overflows in the ZIP64 and implode paths.
+- **cc 1.7.0** (#600), **taiki-e/install-action 2.87.27** (#599), the
+ **Gradle wrapper 9.8.1** (#602; distribution and wrapper-jar SHA-256 both
+ checked against services.gradle.org) and the **Compose compiler plugin
+ 2.4.21** (#603) for the Android app.
+- **rustls 0.23.45 -> 0.23.46**, which fixes four advisories published
+ during this release (RUSTSEC-2026-0346 to 0349: TLS secrets in a session
+ value's `Debug`, ECH early data, a 1 GiB Brotli certificate allocation, a
+ ServerHello key-exchange downgrade). It reaches the netplay WebSocket and
+ HTTP clients through `tungstenite` and `ureq`.
+
+### Records
+
+- **A ninth fuzz target, `movie_import`**, covers the five foreign-movie
+ importers (`.fm2`, `.bk2`, `.fcm`, `.fmv`, `.vmv`), which no target reached:
+ `movie` fuzzes only the `.rnm` decoder. Clean over 2,000,000 inputs when
+ added; CI's `fuzz` job now expects nine targets.
+- **Tools that existed only in untracked scratch are in the repositories.**
+ The release ceremony's local gate runner and squash-merge containment check
+ (`scripts/release-automation/run_gates.sh`, `branch_contained.py`), the
+ quiet-host A/B queue (`scripts/perf/quiet_ab.sh`), and in the MiSTer
+ repository the double-compile behind every "byte-identical across two clean
+ compiles" claim (`scripts/double-compile.sh`) and three gate-writing tools
+ (`tb/mutate_gate.py`, `tb/export_golden.sh`, `tb/sweep_frames.sh`). Each was
+ tested before it was committed: the containment check against a synthetic
+ squash merge, the mutation tool by a real catch and a no-op control.
+- **The three surveys the v3.1-to-v4.0 line plan was drafted from** are kept
+ verbatim in `to-dos/plans/research/`. 25 of the oracle survey's items appear
+ in no plan or ticket, and `T-SURVEY-UNTICKETED` triages them.
+
+### Performance
+
+- **The APU mixes about 2-6% faster when a cartridge adds audio, and 2-3%
+ faster without**, byte-identical, measured in two independent A/B runs
+ (`docs/performance.md`, "v3.2.0 APU"). The change came with an AI-made
+ remediation branch that also made the expansion-audio path 47% SLOWER; the
+ cause, a reshaped ring write, was bisected and left out.
+- **The rest of that branch's speed-ups, measured against the tree before it**
+ (two independent runs each, `docs/performance.md`, "v3.2.0 import"): an
+ MMC3 cartridge read is about 9% faster (its bank offsets are now cached),
+ and run-ahead on an MMC3 game about 5% faster with it. Whole-frame time,
+ PPU throughput and run-ahead on an NROM game do not change measurably. The
+ branch's own figures (PPU 3.5%, mapper reads 11-16%, MMC3 run-ahead
+ 12.8%) were taken against the v3.1.0 tag, and do not hold against the
+ tree it was imported into. Two cartridge reads got
+ slower, MMC5 by about 4.7% and Sunsoft FME-7 by about 6%, although neither
+ read function changed; the cause is not established
+ (`T-PERF-MAPPER-READ-DRIFT`).
+
+### The MiSTer core (`RustyNES_MiSTer`)
+
+- **Two more core options, as on the desktop.** "Sprites per line" draws the
+ sprites past the eighth on a line, matching the emulator's option frame by
+ frame (on-die build only: the extra sprites' pattern fetch is a second read
+ of the on-die CHR memory, which the off-die build does not have; a gate
+ checks it stays absent there). "Load Palette" takes a `.pal` file and applies the
+ emulator's emphasis model to it.
+- **Sixteen more boards**: UN1ROM (94), Nichibutsu UNROM (180), CPROM (13), J87
+ (87), CNROM 185, GxROM (66), Jaleco JF-11/14 (140), Color Dreams (11),
+ NINA-03/06 (79), Camerica 71 (including *Fire Hawk*) and 232, mapper 34
+ (BNROM and NINA-001), Namco 108 (206), TxSROM (118), TQROM (119) and MMC1
+ SUROM (512 KiB, off-die). The NES 2.0 submapper now reaches the cartridge.
+ Each board is gated against the emulator, and for thirteen of them a
+ commercial game's frame is compared too (SUROM's off-die only;
+ `docs/rung7-mappers.md`).
+- **Three input devices**: the Four Score, the Arkanoid (Vaus) controller and
+ Game Genie cheats, gated against goldens exported with the same device.
+- **The custom palette uses 1,004 fewer combinational ALUTs** (2,390 ->
+ 1,386 in synthesis), stored as three per-channel tables. Seven RTL findings from the
+ same AI audit were re-derived; one is a real cost (the nametable is in
+ registers) and is deferred, six did not hold.
+- **The oracle pin moves to v3.2.0**: all 842 golden artifacts from 188 stems
+ reproduce byte for byte from it.
+- **The co-simulation ladder: 259 passed, 0 failed, 3 expected failures
+ on-die, 260 / 0 / 5 off-die** (v3.1.0: 212 / 0 / 1 and 213 / 0 / 1), each
+ one frozen-worktree run with nothing skipped. Two commercial frames still
+ differ from the emulator's and are expected failures, unattributed: one
+ pixel of *Mississippi Satsujin Jiken* and three scanlines of *Micro
+ Machines*.
+- **New release-candidate bitstreams at fitter seed 4**, chosen from seeds 1-8
+ at build date 261014 (seeds 1, 3, 5, 6 and 7 do not close off-die), each
+ compiled twice to the same bytes: on-die md5
+ `e24820de2e302d3f6912524b3c97a2be` (+0.213 / +0.079 ns), off-die
+ `c33ae2b14e5b8e07a68b4335c728ca03` (+0.038 / +0.080 ns, an internal path;
+ the SDRAM read capture has +0.453 / +1.180 ns, assuming zero board delay).
+- **Known issue (off-die build):** a `$2007` read of CHR-RAM soon after a
+ write returns `$00` in simulation. It predates this release, is an expected
+ failure in the off-die ladder, and the on-die build is right.
+- **No hardware has run any bitstream.**
+
+### Fixed
+
+- **FDS disk games keep their saves on Android and iOS.** The game's writes to
+ its disk are saved when the app is backgrounded, a game is switched or closed,
+ and the saved disk boots next time under the original game's identity, so its
+ save states, RetroAchievements progress and settings are still found. Before,
+ a disk game's own save was lost on close. On Android the disk is also checked
+ once a second during play, so a process killed in the background keeps the
+ last second's writes and a failed write is retried, and a game switch can no
+ longer write one game's disk into another game's save or an older copy of the
+ same disk over a newer one; on both, a disk write landing while the save is
+ being written is no longer lost, and on iOS reopening the running disk game no
+ longer boots the save from before its newest writes. (The iOS half compiles
+ in the iOS workflow's Simulator build; neither half has run on a device.)
+- **The Android screen stays on during play** and sleeps again when paused or
+ closed; with a gamepad it used to dim and sleep mid-game.
+- **Mapper 290 (*Asder 20-in-1*) boots.** Its 32 KiB PRG mode read the
+ upper half through the 16 KiB window, so the reset vector came from the
+ wrong bank.
+- **A 24C02 serial EEPROM keeps its word address across a START** (mappers
+ 16 and 157), so a random read returns the byte asked for instead of byte
+ `$00`. On the Datach this was the difference between booting and
+ reformatting the save at every power-on.
+- **The VRC4's IRQ runs in the mode the game asks for** (mappers 21, 23 and
+ 25). Bit 2 of its IRQ control register was read inverted: nesdev's "VRC IRQ"
+ gives 1 = cycle mode, 0 = scanline mode, as the VRC6 and VRC7 already read
+ it. A game that asked for a raster split got a counter about 114 times too
+ fast, and one that asked for cycle mode got a scanline counter. Found by
+ *San Guo Zhi* (mapper 252), whose text box drew with the map's tiles.
+- **VRC2 mirroring has two settings, not four.** A VRC2 ignores bit 1 of its
+ mirroring register (nesdev "VRC2 and VRC4"); the one-screen settings are
+ VRC4-only, and were decoded on the VRC2 too.
+- **Mappers 23 and 25 with NES 2.0 submapper 3 are the VRC2** (VRC2b,
+ VRC2c), built without the VRC4's IRQ and one-screen mirroring. Every 23 / 25
+ image was built as a VRC4 before.
+- ***Fire Hawk* gets its single-screen mirroring from an iNES 1.0 header.**
+ Mapper 71 with submapper 0 switches to the BF9097's `$9000` single-screen
+ register at the first write there, as nesdev's mapper 71 page recommends
+ without a submapper. Before, only an NES 2.0 submapper 1 header enabled it,
+ and the `$9000` write selected a PRG bank instead.
+- **A peer timeout no longer runs a stray frame.** The netplay tick that ends
+ a session on a silent peer told the frontend the session was inactive, so it
+ ran one single-player frame on the failure tick.
+- **A Datach barcode scan starts a fresh rewind and clip history**, as a Reset
+ does: a movie does not record the scan, so a clip spanning it could not
+ replay. The barcode window also closes when a game without a reader loads.
+- **New save files are mode `0o600` exactly**, also under a umask that masks
+ owner bits (they were created `0o600 & ~umask`).
+- **Save-state loads refuse a corrupt blob before changing anything** on the
+ Datach barcode reader (an impossible scan length), the FFE Super Magic Card
+ (an out-of-range FDS IRQ phase), mapper 116 (an MMC1 part refused after the
+ MMC3 part had been restored) and mappers 16/153/157/159 (an unknown
+ mirroring byte). A TXC 172 image with a four-screen header keeps its
+ mirroring across a save and load; it came back horizontal.
+- **Mapper 252 refuses an image without CHR-ROM** (the board is CHR-ROM with
+ two CHR-RAM pages), and the FFE card refuses a trainer shorter than 512
+ bytes instead of panicking.
+
## [3.1.0] - 2026-10-08 - "Bellwether" (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it)
The first release of the v3.1 to v4.0 line. AccuracyCoin is re-synced to its
diff --git a/Cargo.lock b/Cargo.lock
index 94c71eedd..386a54a70 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -608,9 +608,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.6.0"
+version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
+checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -4237,9 +4237,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.45"
+version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
@@ -4290,7 +4290,7 @@ dependencies = [
[[package]]
name = "rustynes-android"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"android-activity",
"android_logger",
@@ -4308,7 +4308,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags 2.13.2",
"criterion",
@@ -4321,7 +4321,7 @@ dependencies = [
[[package]]
name = "rustynes-cheevos"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"cc",
"libc",
@@ -4330,7 +4330,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags 2.13.2",
"criterion",
@@ -4347,7 +4347,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags 2.13.2",
"criterion",
@@ -4358,7 +4358,7 @@ dependencies = [
[[package]]
name = "rustynes-frontend"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"anstyle",
"arboard",
@@ -4417,18 +4417,18 @@ dependencies = [
[[package]]
name = "rustynes-gamedb"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"rustynes-core",
]
[[package]]
name = "rustynes-gfx-shaders"
-version = "3.1.0"
+version = "3.2.0"
[[package]]
name = "rustynes-hdpack"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"lewton",
"png",
@@ -4439,7 +4439,7 @@ dependencies = [
[[package]]
name = "rustynes-ios"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bytemuck",
"cpal",
@@ -4453,7 +4453,7 @@ dependencies = [
[[package]]
name = "rustynes-libretro"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"libc",
"rust-libretro",
@@ -4463,7 +4463,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags 2.13.2",
"criterion",
@@ -4475,8 +4475,9 @@ dependencies = [
[[package]]
name = "rustynes-mobile"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
+ "getrandom 0.2.17",
"rustynes-core",
"rustynes-gamedb",
"rustynes-hdpack",
@@ -4491,7 +4492,7 @@ dependencies = [
[[package]]
name = "rustynes-netplay"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"futures-util",
"js-sys",
@@ -4507,7 +4508,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags 2.13.2",
"criterion",
@@ -4519,21 +4520,21 @@ dependencies = [
[[package]]
name = "rustynes-probe"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"rustynes-core",
]
[[package]]
name = "rustynes-ra"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"rustynes-cheevos",
]
[[package]]
name = "rustynes-script"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"mlua",
"piccolo",
@@ -4544,7 +4545,7 @@ dependencies = [
[[package]]
name = "rustynes-test-harness"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"insta",
"png",
@@ -5030,7 +5031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
@@ -6823,14 +6824,15 @@ dependencies = [
[[package]]
name = "zip"
-version = "8.6.0"
+version = "9.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
+checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
+ "nix 0.31.3",
"typed-path",
"zopfli",
]
diff --git a/Cargo.toml b/Cargo.toml
index 00eec722e..1d6bafecd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -77,7 +77,7 @@ default-members = ["crates/rustynes-libretro"]
# `release-auto.yml` reads the `## [X.Y.Z]` line for BOTH the release body
# fallback and the title codename — so the date and quoted codename are load-
# bearing, not decoration.
-version = "3.1.0"
+version = "3.2.0"
edition = "2024"
# The toolchain is 1.99 (rust-toolchain.toml), and every crate inherits this
# floor, the libretro path included: the libretro buildbot follows the same pin
@@ -99,7 +99,6 @@ rustynes-apu = { path = "crates/rustynes-apu", version = "3.0.0", default-featur
rustynes-mappers = { path = "crates/rustynes-mappers", version = "3.0.0" }
rustynes-netplay = { path = "crates/rustynes-netplay", version = "3.0.0" }
rustynes-script = { path = "crates/rustynes-script", version = "3.0.0" }
-rustynes-test-harness = { path = "crates/rustynes-test-harness", version = "3.0.0" }
# v1.8.0 "Android" — the shared mobile bridge, consumed by rustynes-android (and
# v1.9.0's rustynes-ios). Path dep; the version req is a floor.
rustynes-mobile = { path = "crates/rustynes-mobile", version = "3.0.0" }
diff --git a/OVERVIEW.md b/OVERVIEW.md
index e9a3cddfa..753ad8cf0 100644
--- a/OVERVIEW.md
+++ b/OVERVIEW.md
@@ -2,7 +2,7 @@
**Document Version:** 2.1.0
**Last Updated:** 2026-10-06
-**Applies to:** RustyNES v3.1.0
+**Applies to:** RustyNES v3.2.0
---
@@ -22,9 +22,9 @@
RustyNES is the **definitive NES emulator for the modern era** — combining cycle-perfect accuracy with a complete contemporary feature set and the safety guarantees of Rust. It is more than an emulator: it is a platform for NES preservation, competitive online play, tool-assisted speedrunning, and homebrew development.
-As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the first designated breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** — the access does not increment, it pulses the load already there: the CHR-during-rendering gate closes at 61,440 of 61,440 pixels, and **no hardware has run any bitstream** — built on **v2.6.22 "Rigging"**, the instruments for the board, built before the board: AccuracyCoin reads back from hardware as bytes rather than as a photograph, the catalog turns out to carry 149 rows and 144 results so the headline no longer depends on when the run was sampled (at the same 144/144), and a `rom_sha256` recorded in every golden manifest and compared to nothing is now rung 0 — built on **v2.6.21 "Steward"**, which brought battery saves, the CHR-during-rendering gate RED at 32,861 of 61,440 pixels, and the deploy loop the runbook prescribed and **v2.6.20 "Telltale"**, itself on **v2.6.19 "Accession"** and **v2.6.18 "Errata"**, built on **v2.6.17 "Terminus"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn.
+As of **v1.0.0**, that vision was realized: RustyNES clears the Mesen2 / higan / ares accuracy bar, ships a polished desktop application and a browser build, and supports the full platform surface — netplay, achievements, TAS movies, a debugger, FDS, and arcade (Vs. / PlayChoice-10) hardware. Since then the additive v1.x line added three more platforms (native Android, iOS / iPadOS, and a Libretro / RetroArch core), **v2.0.0 "Timebase"** replaced the scheduler substrate with the one-clock / every-cycle-bus-access model (ADR 0029 — the first designated breaking release), and the v2.1.x → v2.3.x lines deepened accuracy, presentation, and analysis tooling. The current release is **v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. Built on **v2.8.1 "Gasket"** — the libretro core fits the frontends around it: four-player games work through a Four Score option, a controller works again after its port leaves the Zapper, RetroArch no longer reads past the core's input-descriptor list, expansion audio no longer clips, the core declares UNIF images, and the Makefile honours PREFIX, platform=win, DEBUG and CARGO_TARGET_DIR. Built on **v2.8.0 "Bulkhead"** — the libretro core stops a fault at its own boundary: an internal error no longer closes RetroArch, save states survive plugging in a Zapper, closing a game withdraws its memory maps, the core loads from any libretro frontend, and the save state now carries the 2A03 internal data bus. Built on **v2.7.6 "Recount"** — the v2.7.5 deletions measured one at a time: the six performance proposals v2.7.5 bounded only together were each measured alone, where the benchmarks reach them: five are zero, and the sixth, the pulse sweep-mute check, bounds at about 0.2%, and the cheap byte-identical way to take it measured slower; the fast render path now asserts a rendering-history invariant it used to re-write; and the libretro buildbot builds macOS again and gains 32-bit Windows, 32-bit Linux and webOS targets. Built on **v2.7.5 "Tally"** — every audit claim closed with a measurement or a reason: the core audit's twelve performance proposals were closed, eleven of them by measurement, and one adopted (the audio buffer keeps its capacity between frames); 18 dead bus methods and the unused ApuBus trait are deprecated; and the core and frontend ledgers have no open row. Built on **v2.7.4 "Pocket"** — the mobile apps survive what a phone does to them: an internal error no longer closes the Android or iOS app, battery saves persist on both, the apps pause and give up audio when they should, and saves are written so a dying phone keeps the last good one. Built on **v2.7.3 "Hearth"** — the desktop and web frontends keep what they are given: battery saves persist on the desktop, a Lua script can no longer hang or exhaust the emulator, script HTTP cannot reach local services by default, and audio survives a device change. Built on **v2.7.2 "Bankroll"** — cartridge memory, every bank a cartridge has and nothing it has not: MMC1 reaches SUROM / SXROM, MMC5 banks its PRG-RAM, Namco 163 selects nametables, and `$6000-$7FFF` reads open bus where a board has nothing there. Built on **v2.7.1 "Keepsake"** — six cartridge boards now hand RetroArch their battery save instead of an empty one, every user file the frontend writes is written atomically, and three mapper files are now recorded as derived from Mesen2 and puNES. Built on **v2.7.0 "Palisade"** — a corrupt or hand-edited save state now fails at restore with a typed error instead of crashing the emulator one tick later, pulse 1 no longer mutes on the `$4001 = $08` sweep idiom, and the save-state fuzz target can finally reach what it exists to find. Built on **v2.6.23 "Pulse"** — the access does not increment, it pulses the load already there: the CHR-during-rendering gate closes at 61,440 of 61,440 pixels, and **no hardware has run any bitstream** — built on **v2.6.22 "Rigging"**, the instruments for the board, built before the board: AccuracyCoin reads back from hardware as bytes rather than as a photograph, the catalog turns out to carry 149 rows and 144 results so the headline no longer depends on when the run was sampled (at the same 144/144), and a `rom_sha256` recorded in every golden manifest and compared to nothing is now rung 0 — built on **v2.6.21 "Steward"**, which brought battery saves, the CHR-during-rendering gate RED at 32,861 of 61,440 pixels, and the deploy loop the runbook prescribed and **v2.6.20 "Telltale"**, itself on **v2.6.19 "Accession"** and **v2.6.18 "Errata"**, built on **v2.6.17 "Terminus"**. The never-tagged v2.4.0 "Concordance" shipped inside **v2.4.1 "Fabric"** — this sentence had attached that fact to whichever release was current, carried forward by three mechanical version bumps, and said it of v2.4.2, v2.4.3 and v2.4.4 in turn.
-> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v3.1.0**.
+> RustyNES's emulation core descends from an extensively-documented accuracy program. Where this and related docs reference deep "v1.x"/"v2.x" engine narrative, read it as upstream engine lineage (engineering history), not as RustyNES release versions. Two distinct "v2.0"s exist and must not be conflated: the engine-lineage v2.0 master-clock work shipped as RustyNES **v1.0.0**, while RustyNES's own **v2.0.0 "Timebase"** (2026-07-03) is the later release that *replaced* that same scheduler. The current release is **v3.2.0**.
---
@@ -79,7 +79,7 @@ RustyNES uses **cycle-accurate** emulation rather than scanline-based shortcuts.
1. **Emulation enthusiasts** — reference-grade accuracy with a modern, themeable desktop UX and an in-app debugger.
2. **The TAS community** — frame-perfect deterministic `.rnm` movie record / playback / branching built directly on the determinism contract.
3. **Netplay users** — GGPO-style rollback netplay (2–4 players), native (UDP) and in the browser (WebRTC).
-4. **Homebrew developers** — broad mapper coverage (191 families), FDS, an instruction/PPU/memory debugger, and an embeddable `no_std` core.
+4. **Homebrew developers** — broad mapper coverage (208 families), FDS, an instruction/PPU/memory debugger, and an embeddable `no_std` core.
5. **Rust developers** — a clean, modular workspace and a reusable 6502 CPU crate.
---
@@ -89,7 +89,7 @@ RustyNES uses **cycle-accurate** emulation rather than scanline-based shortcuts.
| Area | What ships today |
|------|----------------------|
| **Accuracy** | One-clock scheduler (v2.0.0 "Timebase"), master-clock timebase, AccuracyCoin **146/146 (100.00%)** on the battery as re-synced to upstream `f5f41dc2` in v3.1.0 (the 144/144 before it hid a masked failure), `nestest` 0-diff |
-| **Cartridges** | **191** mapper families incl. expansion audio (VRC6/VRC7-OPLL/Sunsoft 5B/N163/MMC5) |
+| **Cartridges** | **208** mapper families incl. expansion audio (VRC6/VRC7-OPLL/Sunsoft 5B/N163/MMC5) |
| **Platforms** | iNES / NES 2.0, Famicom Disk System (real-BIOS boot, read/write, multi-side), Vs. System / PlayChoice-10 RGB |
| **Online** | Rollback netplay, UDP (native) + WebRTC (browser), 2–4 players |
| **Achievements** | RetroAchievements (opt-in, native-only) — login, hardcore, toasts, badges |
diff --git a/README.md b/README.md
index 27e1b8884..fe6d17e77 100644
--- a/README.md
+++ b/README.md
@@ -9,8 +9,8 @@
-
-
+
+
@@ -21,7 +21,7 @@ sprite-zero hit, a mid-scanline scroll write or an MMC3 IRQ lands on the exact d
without a per-game patch. It passes **AccuracyCoin 146/146** and matches the
Nintendulator `nestest` log with **zero diff**.
-Around that core sits a complete, modern platform: 191 mapper families, the
+Around that core sits a complete, modern platform: 208 mapper families, the
Famicom Disk System, Vs. System and PlayChoice-10 arcade boards, rollback
netplay, RetroAchievements, a TAStudio piano-roll editor, a Mesen2-class
debugger, HD packs, a shader stack, and native apps for desktop, the browser,
@@ -73,7 +73,7 @@ audience: players, RetroArch, mobile and the Rust crates.
| | |
| --- | --- |
| **Cycle-accurate** | CPU, PPU and APU on one master clock: AccuracyCoin 146/146, `nestest` 0-diff, blargg's CPU, APU and PAL suites |
-| **191 mapper families** | NROM through MMC5, the whole VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company, the MMC3 and MMC1 multicarts, Waixing and Nanjing boards, homebrew flash boards with working saves, and a UNIF (`.unf`) loader. Each is classified Core, Curated or BestEffort by the evidence behind it |
+| **208 mapper families** | NROM through MMC5, the whole VRC line, Sunsoft FME-7, Namco 163, Taito, J.Y. Company, the MMC3 and MMC1 multicarts, Waixing and Nanjing boards, homebrew flash boards with working saves, and a UNIF (`.unf`) loader. Each is classified Core, Curated or BestEffort by the evidence behind it |
| **Famicom Disk System** | Real-BIOS boot, writable disks, side swapping, a timed disk-head model and 2C33 wavetable audio |
| **Vs. / PlayChoice-10** | Arcade boards in true 2C03 / 2C04 / 2C05 RGB, per-game DIP presets, and Vs. DualSystem two-screen cabinets |
| **Rollback netplay** | GGPO-style, up to four players over UDP or browser WebRTC, with room codes, TURN traversal and spectators; peers must match on ROM, emulation options and emulation epoch, so a session cannot silently diverge, and a mismatch is refused with its reason |
@@ -166,7 +166,7 @@ in `crates/rustynes-frontend/web`.
### Cartridges
-- **191 mapper families**, written from the NESdev wiki and pinned by test ROMs,
+- **208 mapper families**, written from the NESdev wiki and pinned by test ROMs,
unit tests and a commercial-ROM oracle. [`docs/mappers.md`](docs/mappers.md)
lists every family, its tier and its evidence.
- **Self-flashing homebrew boards** (GTROM, UNROM 512) emulate their SST39SF040
@@ -280,9 +280,9 @@ and the per-suite detail is in [`docs/STATUS.md`](docs/STATUS.md).
When a document and a passing test ROM disagree, the ROM wins: that is this
project's definition of cycle-accurate.
-**Mapper tiers.** Of the 191 families, 160 are *accuracy-gated*: 51 Core and 109
+**Mapper tiers.** Of the 208 families, 181 are *accuracy-gated*: 51 Core and 130
Curated, each backed by a test ROM, a commercial dump, or a precise register
-description with a synthetic fixture. The other 31 are *BestEffort*: their
+description with a synthetic fixture. The other 27 are *BestEffort*: their
documentation is thin, and a CI gate keeps them out of every accuracy claim.
---
@@ -326,7 +326,7 @@ detailed in [`docs/architecture.md`](docs/architecture.md) and
| Crate | Role |
| --- | --- |
| `rustynes-cpu`, `rustynes-ppu`, `rustynes-apu` | The 6502, 2C02 and 2A03 |
-| `rustynes-mappers` | 191 mapper families, expansion audio, the UNIF loader |
+| `rustynes-mappers` | 208 mapper families, expansion audio, the UNIF loader |
| `rustynes-core` | The bus, scheduler, console and save states |
| `rustynes-frontend` | The `winit` + `wgpu` + `cpal` + `egui` application (`rustynes`) |
| `rustynes-netplay` | Rollback netcode over UDP and WebRTC |
@@ -357,7 +357,7 @@ detailed in [`docs/architecture.md`](docs/architecture.md) and
## Current release
-RustyNES's current release is **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0.
+RustyNES's current release is **v3.2.0 "Confluence"** (2026-10-14) — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (2026-10-08) — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (2026-10-07) — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** (2026-10-06) — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** (2026-10-04) — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** (2026-10-02) — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** (2026-09-30) — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** (2026-09-30) — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0.
**v3.0.0 is the API major** ([ADR 0043](docs/adr/0043-v3-is-the-api-major-and-a-release-candidate-core.md)).
It gathers every breaking change since v2.x, most of them made early in v2.9.8 and
@@ -396,12 +396,13 @@ ported to an FPGA: what can be built is a new implementation checked against thi
one, cycle by cycle.
Its co-simulation ladder covers the 6502, the bus and interrupts, the 2C02, the
-2A03, AccuracyCoin parity and six mapper boards. Each release attaches a timing-closed
-bitstream pair; v3.0.1's (on-die and off-die, both at fitter seed 2, each compiled
-twice to the same bytes) is a **release candidate, not hardware-verified**. Its
-co-simulation ladder reads 200 passed, 0 failed, 1 expected failure on-die and
-201 / 0 / 1 off-die. **No hardware has run any bitstream yet**, so a booting core, a synced
-display, audible sound and a working pad are not claimed. The details, and what
+2A03, AccuracyCoin parity and twenty-one iNES mappers. Each release attaches a
+timing-closed bitstream pair; v3.2.0's (on-die and off-die, both at fitter seed 4,
+each compiled twice to the same bytes) is a **release candidate, not
+hardware-verified**. Its co-simulation ladder reads 259 passed, 0 failed, 3
+expected failures on-die and 260 / 0 / 5 off-die. **No hardware has run any
+bitstream yet**, so a booting core, a synced display, audible sound and a working
+pad are not claimed. The details, and what
each rung can and cannot verify, are in [`docs/mister.md`](docs/mister.md).
diff --git a/ROADMAP.md b/ROADMAP.md
index 781b361d2..4a5f0d420 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,13 +2,13 @@
**Document Version:** 2.0.4
**Last Updated:** 2026-10-06
-**Project Status:** v3.1.0 "Bellwether" released — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** (a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0) and **v3.0.0 "Cornerstone"** (the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core) and **v2.9.9 "Ballast"** (the release candidate for v3.0.0: all four audit scopes re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it with the release-candidate bitstream pair; the tenth release of the v2.9.x line and the sixth of the line to v3.0.0 (ADR 0043, amended)) and **v2.9.8 "Vanguard"** (v3.0.0's breaking changes landed early) and **v2.9.7 "Tandem"** (the desktop's features on the web and on phones). **No hardware has run any bitstream**; the mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29).
+**Project Status:** v3.2.0 "Confluence" released — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** (the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it) and **v3.0.1 "Mortar"** (a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0) and **v3.0.0 "Cornerstone"** (the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core) and **v2.9.9 "Ballast"** (the release candidate for v3.0.0: all four audit scopes re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it with the release-candidate bitstream pair; the tenth release of the v2.9.x line and the sixth of the line to v3.0.0 (ADR 0043, amended)) and **v2.9.8 "Vanguard"** (v3.0.0's breaking changes landed early) and **v2.9.7 "Tandem"** (the desktop's features on the web and on phones). **No hardware has run any bitstream**; the mobile device runs and the SuperStation One board session move after v3.0.0 (maintainer, 2026-09-29).
---
## Where we are
-RustyNES is well past v1.0.0. The current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.**
+RustyNES is well past v1.0.0. The current release is **v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.**
**This root ROADMAP is a historical snapshot of the v1.0.0 cut.** For the authoritative, current forward roadmap see **[`to-dos/ROADMAP.md`](to-dos/ROADMAP.md)**; for the authoritative current-state pass counts and platform matrix see **[`docs/STATUS.md`](docs/STATUS.md)**; for the full per-release history see **[`CHANGELOG.md`](CHANGELOG.md)**. Many of the "post-1.0 directions" listed further down (mobile, Lua scripting, TAS editor, Vs. DualSystem, HD packs, hosted netplay) have since shipped — the tables below record what was **done at v1.0.0**, not the current feature set.
diff --git a/SECURITY.md b/SECURITY.md
index 66528b90b..ed57e33c8 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,7 +2,7 @@
## Supported Versions
-The current release is **v3.1.0 "Bellwether"**. Built on **v3.0.1 "Mortar"** and **v3.0.0 "Cornerstone"** and **v2.9.9 "Ballast"** and **v2.9.8 "Vanguard"** and **v2.9.7 "Tandem"** and **v2.9.6 "Roster"** and **v2.9.5 "Caliper"** and **v2.9.4 "Plumb"** and **v2.9.3 "Handset"** and **v2.9.2 "Candidate"** and **v2.9.1 "Hone"** and **v2.9.0 "Survey"** and **v2.8.4 "Tether"** and **v2.8.3 "Rivet"** and **v2.8.2 "Solder"** and **v2.8.1 "Gasket"** and **v2.8.0 "Bulkhead"** and **v2.7.6 "Recount"** and **v2.7.5 "Tally"** and **v2.7.4 "Pocket"** and **v2.7.3 "Hearth"** and **v2.7.2 "Bankroll"** and **v2.7.1 "Keepsake"** and **v2.7.0 "Palisade"** and **v2.6.23 "Pulse"** and **v2.6.22 "Rigging"** and **v2.6.21 "Steward"** and **v2.6.20 "Telltale"** and **v2.6.19 "Accession"** and **v2.6.18 "Errata"** and **v2.6.17 "Terminus"** and **v2.6.16 "Interlock"** and **v2.6.15 "Warrant"** and **v2.6.14 "Docket"** and **v2.6.13 "Slack"** and **v2.6.12 "Groundwork"** and **v2.6.11 "Exposure"** and **v2.6.10 "Inference"** and **v2.6.9 "Abeyance"** and **v2.6.8 "Arrears"** and **v2.6.7 "Detent"** and **v2.6.6 "Chassis"** and **v2.6.5 "Muster"** and **v2.6.4 "Rubric"** and **v2.6.3 "Mainspring"** and **v2.6.2 "Witness"** and **v2.6.1 "Interleave"** and **v2.6.0 "Assay"** and **v2.5.9 "Overture"** and **v2.5.8 "Blanking"** and **v2.5.7 "Collimation"** and **v2.5.6 "Vestige"** and **v2.5.5 "Raster"** and **v2.5.4 "Escapement"** and **v2.5.3 "Hysteresis"** and **v2.5.2 "Dormant"** and **v2.5.1 "Retrace"** and **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a
+The current release is **v3.2.0 "Confluence"**. Built on **v3.1.0 "Bellwether"** and **v3.0.1 "Mortar"** and **v3.0.0 "Cornerstone"** and **v2.9.9 "Ballast"** and **v2.9.8 "Vanguard"** and **v2.9.7 "Tandem"** and **v2.9.6 "Roster"** and **v2.9.5 "Caliper"** and **v2.9.4 "Plumb"** and **v2.9.3 "Handset"** and **v2.9.2 "Candidate"** and **v2.9.1 "Hone"** and **v2.9.0 "Survey"** and **v2.8.4 "Tether"** and **v2.8.3 "Rivet"** and **v2.8.2 "Solder"** and **v2.8.1 "Gasket"** and **v2.8.0 "Bulkhead"** and **v2.7.6 "Recount"** and **v2.7.5 "Tally"** and **v2.7.4 "Pocket"** and **v2.7.3 "Hearth"** and **v2.7.2 "Bankroll"** and **v2.7.1 "Keepsake"** and **v2.7.0 "Palisade"** and **v2.6.23 "Pulse"** and **v2.6.22 "Rigging"** and **v2.6.21 "Steward"** and **v2.6.20 "Telltale"** and **v2.6.19 "Accession"** and **v2.6.18 "Errata"** and **v2.6.17 "Terminus"** and **v2.6.16 "Interlock"** and **v2.6.15 "Warrant"** and **v2.6.14 "Docket"** and **v2.6.13 "Slack"** and **v2.6.12 "Groundwork"** and **v2.6.11 "Exposure"** and **v2.6.10 "Inference"** and **v2.6.9 "Abeyance"** and **v2.6.8 "Arrears"** and **v2.6.7 "Detent"** and **v2.6.6 "Chassis"** and **v2.6.5 "Muster"** and **v2.6.4 "Rubric"** and **v2.6.3 "Mainspring"** and **v2.6.2 "Witness"** and **v2.6.1 "Interleave"** and **v2.6.0 "Assay"** and **v2.5.9 "Overture"** and **v2.5.8 "Blanking"** and **v2.5.7 "Collimation"** and **v2.5.6 "Vestige"** and **v2.5.5 "Raster"** and **v2.5.4 "Escapement"** and **v2.5.3 "Hysteresis"** and **v2.5.2 "Dormant"** and **v2.5.1 "Retrace"** and **v2.5.0 "Rungwork"** and **v2.4.9 "Plumbline II"** and **v2.4.8 "Palimpsest"** and **v2.4.7 "Keystone"** and **v2.4.6 "Abacus"** and **v2.4.5 "Compass"** and **v2.4.4 "Ignition"** and **v2.4.3 "Touchstone"** and **v2.4.2 "Cairn"** and **v2.4.1 "Fabric"**, which also carries the never-tagged v2.4.0 "Concordance". RustyNES ships from `main` on a
rolling patch cadence rather than maintaining long-lived release branches, so
security fixes land in the next patch release rather than being backported.
Report against the latest release or `main`.
@@ -10,7 +10,8 @@ Report against the latest release or `main`.
| Version | Supported | Notes |
| ------------- | --------- | ----- |
| main | Yes | Where fixes land first |
-| 3.1.x | Yes | The current line |
+| 3.2.x | Yes | The current line |
+| 3.1.x | Partial | Fixes are shipped forward into the current line, not backported |
| 3.0.x | Partial | Fixes are shipped forward into the current line, not backported |
| 2.9.x | Partial | Fixes are shipped forward into the current line, not backported |
| 2.8.x | Partial | Fixes are shipped forward into the current line, not backported |
diff --git a/SUPPORT.md b/SUPPORT.md
index 452be26ba..1fb40b222 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -94,7 +94,7 @@ A: RustyNES is a cycle-accurate NES emulator written in pure Rust, clearing the
**Q: Can I use RustyNES now?**
-A: Yes. RustyNES is well past its first stable release — the current release is **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.**
+A: Yes. RustyNES is well past its first stable release — the current release is **v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). **No hardware has run any bitstream.**
**Q: How accurate is RustyNES?**
@@ -116,7 +116,7 @@ A: Native Windows, Linux, and macOS, plus a WebAssembly / GitHub Pages browser b
**Q: What ROMs are supported?**
-A: iNES and NES 2.0 ROM formats across **191 mapper families** (including expansion audio) plus the UNIF (`.unf`) container, the Famicom Disk System (real-BIOS boot), and Vs. System / PlayChoice-10 arcade hardware. Additional mapper families are added demand-driven; see [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md).
+A: iNES and NES 2.0 ROM formats across **208 mapper families** (including expansion audio) plus the UNIF (`.unf`) container, the Famicom Disk System (real-BIOS boot), and Vs. System / PlayChoice-10 arcade hardware. Additional mapper families are added demand-driven; see [`to-dos/ROADMAP.md`](to-dos/ROADMAP.md).
**Q: Does RustyNES support [feature]?**
diff --git a/VERSION-PLAN.md b/VERSION-PLAN.md
index 54a4f7892..d96c3e23a 100644
--- a/VERSION-PLAN.md
+++ b/VERSION-PLAN.md
@@ -1,6 +1,6 @@
# RustyNES Version Plan
-**Current release: v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. **No hardware has run any bitstream.**
+**Current release: v3.2.0 "Confluence"** — every v3.2.0 branch in both repositories brought into one release: 17 mapper families (208 in all), the VRC4 IRQ fix, an AI remediation branch audited change by change, netplay protocol 8, slim libretro states, and sixteen more boards on the MiSTer core. Built on **v3.1.0 "Bellwether"** — the AccuracyCoin re-sync and an honest 146/146, the CPU overclock and the sprite-limit option in movies and netplay, PAL emphasis, the alternate MMC3, rewind and run-ahead on the Vs. cabinet, and the MiSTer core matched to all of it. Built on **v3.0.1 "Mortar"** — a maintenance release: one game's graphics fixed, the MiSTer core's last MMC3 rule exception tested, Rust 1.99 everywhere, every unanswered bot review answered, and the plan to v4.0.0. Built on **v3.0.0 "Cornerstone"** — the API major: every break since v2.x in one place, a core timing epoch for movies and netplay, the last MMC3 timing gap closed in both cores, and a release-candidate MiSTer core. Built on **v2.9.9 "Ballast"** — the release candidate for v3.0.0: the audits re-run, MMC3 and MMC5 by their documentation, audio exact across save states, and the MiSTer core moved onto it. Built on **v2.9.8 "Vanguard"** — the preparation release for v3.0.0: v3.0.0's breaking changes landed early (a save identity that ignores the header, old states and movies refused, movies and netplay that record the machine, the API removals), every staged game was booted and the defects found were fixed, and the game database's corrections reach every platform. Built on **v2.9.7 "Tandem"** — the desktop's features on the web and on phones, the release binaries built with every native feature, and a PPU A12 fix found by real games: Acclaim's MC-ACC games, the J.Y. ASIC and mapper 91 now count at their documented rates. Built on **v2.9.6 "Roster"** — seventeen mapper families written from their NESdev pages (174 → 191), GTROM promoted to Curated with a modelled flash chip whose saves persist, mapper 4's NES 2.0 submappers corrected (MMC6, NEC, MC-ACC, T9552), and the local commercial suites re-baselined after drifting unread since about v2.0.0. Built on **v2.9.5 "Caliper"** — every open accuracy item measured, then fixed or closed: four fixes red first (the `apu_test` frame-counter coincidence, the composite 2C02 scanline-0 sprite glitch, OAM DMA filling the PPU I/O latch, KS7032 at `$6000`), 49 unreferenced test ROMs gated, the MMC3 M2-edge filter lever tried and refuted, and a save-state epoch (`PPU_SNAPSHOT_VERSION` 11). Built on **v2.9.4 "Plumb"** — the records made true, and CI made to run what it only linted: v3.0.0 decided as the API major with a release-candidate core (ADR 0043), CI now running 63 feature-gated tests it never ran, the eight fuzz targets and a 70% line-coverage floor, the mapper tiers, store status and deferred-features catalogue corrected against the code, and the OAM-decay model recorded as derived from Mesen2. Built on **v2.9.3 "Handset"** — the old review threads closed and the mobile run prepared: every dependency moved to its newest release (egui 0.36 with wgpu 30, rcheevos 12.5.0), all 244 review threads left unanswered on PRs #7-#97 answered and the ten findings that still held fixed (Action 53 multicarts rebuilt to the NESdev spec, and a ROM header editor that no longer rewrites bytes you did not edit or saves mappers from 16 up as the wrong mapper), and the Android unit tests and the iOS renderer added to CI. Built on **v2.9.2 "Candidate"** — the full audit acted on, and the release-candidate pair: all 32 findings of a fifth audit have a verdict and 16 are fixed, save states keep the cartridge RAM of twelve board families they used to drop, the MiSTer core no longer loses an NMI raised inside a DMA, and both bitstreams are cut for the SuperStation One session. Built on **v2.9.1 "Hone"** — what the optimisation bars measure, and what clears them: the A/B tool had been timing the old code on both sides of every code comparison and is fixed, a two-screen Vs. cabinet saves about 9x faster, the off-die MiSTer build keeps CHR in its own SDRAM bank, and both bitstreams are pinned at fitter seed 2 and rebuild byte-identically. Built on **v2.9.0 "Survey"** — every audit re-checked, and the SuperStation One surveyed: a Power Cycle no longer erases your save, the off-die MiSTer build boots without the menu core, and 39 new audit findings are fixed or dispositioned. Built on **v2.8.4 "Tether"** — the MiSTer core's SDRAM build, made trustworthy: its controller now reads data on the edge the memory presents it (every off-die read would have been wrong on hardware, and only the new SDRAM timing constraints could see it), the power-up sequence and CAS-latency-3 reads follow the datasheet, the arbiter can no longer return the wrong byte or lose a write, the off-die bitstream builds from a script, both builds are swept and pinned at fitter seed 5, and the co-simulation ladder runs all 165 gates from a clean checkout. Built on **v2.8.3 "Rivet"** — the MiSTer core's reset, area and comments, measured: every reset is released on the clock that uses it and the timing analysis now checks each release, the CPU is about 4% smaller by two exact rewrites the fit report confirmed, four false comments are corrected, and the co-simulation ladder runs from a fresh checkout (164 of its 165 gates; the last needs a hand-built ROM no generator produces). Built on **v2.8.2 "Solder"** — the MiSTer core's on-die RTL, corrected against the oracle and the wiki: an MMC3 IRQ acknowledge is no longer lost to a same-edge counter clock, SNROM's battery RAM obeys its CHR-line enable, the triangle and noise drop a reload landing on a length clock, a `$2002` read leaves the byte it returned on the data bus, and the emulator's MMC1 no longer ignores a reset written on the cycle after another write. **No hardware has run any bitstream.**
RustyNES follows [Semantic Versioning 2.0.0](https://semver.org/).
@@ -64,7 +64,7 @@ released version; these are plans, and `to-dos/plans/` holds the detail.
The line from v3.1.0 to v4.0.0 is indexed in
[`v3.1-to-v4.0-line-plan.md`](to-dos/plans/v3.1-to-v4.0-line-plan.md), drafted
-2026-10-07 with the maintainer's decisions D1-D29. Version numbers after v3.1.0
+2026-10-07 with the maintainer's decisions D1-D29. Version numbers after v3.2.0
are slots. The hardware release is the last of v3.9.x, after v3.9.0's RTL
feature freeze and before v4.0.0 (D29); it takes a number only after its board
session (D1), and folds into v4.0.0 if its fixes are large. (The first draft
@@ -73,7 +73,6 @@ day.)
| Version | Scope | Plan |
|---------|-------|------|
-| v3.2.0 | Mapper breadth by real titles, the dump corpus, KNOWN_BLANK triage, tier promotions. MiSTer F1: options and about ten cheap families, paddle, Four Score, cheats | [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md) |
| v3.3.0 | Phi2 write placement and the sprite-0 stale shifter; wgpu 31 / egui 0.37. MiSTer F2: the SDRAM arbiter, DDR3, save states, rewind; the off-die build becomes the headline | [`v3.3.0-plan.md`](to-dos/plans/v3.3.0-plan.md) |
| v3.4.0 | Hosted netplay and the browser RA proxy on Cloudflare, RA hardcore compliance, native 3-4 player netplay. MiSTer F3a: MMC2/4, FME-7/5B, VRC2/4, the Zapper | [`v3.4.0-plan.md`](to-dos/plans/v3.4.0-plan.md) |
| v3.5.0 | The full movie input stream, Lua sockets and shared memory, desktop polish. MiSTer F3b: MMC5, N163, VRC6/7, Bandai FCG, expansion audio | [`v3.5.0-plan.md`](to-dos/plans/v3.5.0-plan.md) |
@@ -166,7 +165,8 @@ The 1.x line was **additive / off-by-default** — every release stayed byte-ide
| **v2.7.6 "Recount"** | The v2.7.5 deletions measured one at a time -- a measurement release between the v2.7.x and v2.8.x audit lines. v2.7.5 bounded six of the core audit's proposals (§3.1 A/B/C, the IMP-06 stores, §3.5b, §3.6) only in one combined ceiling; each was re-run alone, twice, with the A/B/A control. Five are zero; §3.5b had never been reached (the bench ROMs are silent, 0 sweep-mute checks per frame), and on `spritecans.nes` bounds at about 0.2%, with the cheap byte-identical candidate 0.67% slower, so it is rejected. Three of the IMP-06 stores re-wrote fields the fast path's guard already requires, and are now a `debug_assert!` pinned by a unit test (no ROM reaches the violating state). §3.1 B and C are dead work that goes with the v2.9.0 deprecation decision. Also ships #554 (contributed): the macOS buildbot jobs build again, and 32-bit Windows, 32-bit Linux and webOS targets were added. Plan: `to-dos/plans/v2.7.6-recount-plan.md` |
| **v2.8.0 "Bulkhead"** | The libretro core stops a fault at its own boundary -- the first release of the v2.8.x audit line, from the libretro audit's FFI-safety findings (L-1.1 to L-1.5, L-2.1, L-2.2, L-2.4), each shown failing in a new C-ABI harness that drives the core as a frontend does. The core was built `panic = "abort"`, so its handlers were dead code; it now unwinds on every supported build path (+0.1% instructions per frame; a direct `cargo build --release` still aborts, and now warns and logs it), and every emulating callback contains a panic, stops the game and keeps the console so RetroArch's memory pointers stay valid. `serialize_size` reserves room for the largest expansion device on both ports (a Zapper no longer breaks every later save) and the reader treats an all-zero tail as padding; unload withdraws the memory maps; the vendored `rust-libretro-sys` writes out `retro_game_info`, so the core loads without `GET_GAME_INFO_EXT`. The save state gains the 2A03 internal data bus (older states still load), and the schema audit now covers the bus. No emulation behaviour change; AccuracyCoin 144/144, nestest 0-diff. Plan: `to-dos/plans/v2.8.0-bulkhead-plan.md` |
| **v3.0.1 "Mortar"** | A maintenance release on v3.0.0, plan [`v3.0.1-mortar-plan.md`](to-dos/plans/v3.0.1-mortar-plan.md). Mapper 45's CHR-RAM is unbanked, so *Famicom Yarou Vol.1* draws its menu (T-GA23C-CHRRAM; `EMULATION_EPOCH` 2 refuses v3.0.0 movies and netplay peers). The MiSTer core's dot-0 A12 rule is gated on whether cycle 0 was rendering, found by a new odd-frame stimulus (`mapper4mmc3oddskip080`, plus an /IRQ-rise comparison); new bitstreams at seed 2 / 261007, a release candidate, not hardware-verified. Rust 1.99 and every dependency at its newest, the libretro buildbot included once test pipeline 119614 passed 15/15. Every bot review left unanswered since PR #1 answered (473 verdicts, 80 fixes). The shared Bisqwit NTSC pass recorded as derived; the TriCNES source moved out of the repository. The plan to v4.0.0 written from 29 maintainer decisions |
-| **v3.1.0 "Bellwether"** (current) | The first release of the v3.1 to v4.0 line, plan [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md). AccuracyCoin re-synced to upstream `f5f41dc2`, 146 of 146; the new ROM showed that every earlier 100% included a `Misaligned OAM behavior` failure the old ROM hid, and both defects it found are fixed (`EMULATION_EPOCH` 3, BUS section 3, `PPU_SNAPSHOT_VERSION` 13, `.rnm` 6, netplay 7: v3.0.1 states, movies and peers are refused). The CPU overclock and the sprite-limit option work and ride in `HardwareOptions`; PAL and Dendy emphasis swap red and green; the alternate MMC3 is selectable and asserts on a `$C001` reload to 0; differential phase in the NTSC decode; rewind and run-ahead on the Vs. cabinet; an epoch fingerprint gate. The MiSTer core matches the re-synced battery on all 146 entries (the misaligned X mask, the DMC reload edge), matches the emulator on a `$2006` copy landing on every dot (RTL-1, provisional until a board settles it), gates 30 of 34 AccuracyCoin sub-test ROMs, and moves its pin to v3.1.0. **No hardware has run any bitstream.** |
+| **v3.2.0 "Confluence"** (current) | The second release of the v3.1 to v4.0 line, plan [`v3.2.0-plan.md`](to-dos/plans/v3.2.0-plan.md), and every v3.2.0 branch in both repositories brought into one. 17 mapper families (191 to 208, among them the Bandai Datach with its barcode reader), 47/121/154/191/243 promoted to Curated, tiers 51 / 130 / 27 (181 of 208 accuracy-gated). The VRC4 IRQ mode bit is read the right way round, VRC2 mirroring has its two settings, *Fire Hawk* keeps single-screen mirroring on an iNES 1.0 header, mapper 290 boots and the 24C02 keeps its word address across a START (`EMULATION_EPOCH` 4: v3.1.x movies and peers are refused). An AI remediation branch (61 findings) audited change by change: 20 kept, 14 fixed, 4 reverted, 10 rejected, its APU speed-ups kept byte-identical and the 47% expansion-audio slowdown it introduced removed. Netplay protocol 8 (`"RNE8"`), libretro states of about 16 KB, owner-only save files. The MiSTer core gains sixteen boards, the Four Score, the Arkanoid controller, Game Genie cheats, more than eight sprites per line (on-die) and custom palettes, moves its pin to v3.2.0 (842 golden artifacts reproduce byte for byte), and ships bitstreams at fitter seed 4 (build date 261014). **No hardware has run any bitstream.** |
+| **v3.1.0 "Bellwether"** | The first release of the v3.1 to v4.0 line, plan [`v3.1.0-plan.md`](to-dos/plans/v3.1.0-plan.md). AccuracyCoin re-synced to upstream `f5f41dc2`, 146 of 146; the new ROM showed that every earlier 100% included a `Misaligned OAM behavior` failure the old ROM hid, and both defects it found are fixed (`EMULATION_EPOCH` 3, BUS section 3, `PPU_SNAPSHOT_VERSION` 13, `.rnm` 6, netplay 7: v3.0.1 states, movies and peers are refused). The CPU overclock and the sprite-limit option work and ride in `HardwareOptions`; PAL and Dendy emphasis swap red and green; the alternate MMC3 is selectable and asserts on a `$C001` reload to 0; differential phase in the NTSC decode; rewind and run-ahead on the Vs. cabinet; an epoch fingerprint gate. The MiSTer core matches the re-synced battery on all 146 entries (the misaligned X mask, the DMC reload edge), matches the emulator on a `$2006` copy landing on every dot (RTL-1, provisional until a board settles it), gates 30 of 34 AccuracyCoin sub-test ROMs, and moves its pin to v3.1.0. **No hardware has run any bitstream.** |
| **v3.0.0 "Cornerstone"** | The API major -- the first release of the v3 line (ADR 0043). It gathers every break since v2.x in one place (the header-free game identity, refused older states and movies, the protocol and API changes of v2.8.x-v2.9.9) and adds the last: a core timing epoch that movies (`.rnm` format 5) and netplay (protocol 6, `"RNE6"`) carry, so a version that emulates differently is refused with a reason (ADR 0045); `Cartridge`, `BoardDescription`, `HardwareOptions` and `Movie` `#[non_exhaustive]`; `MapperError::Truncated` renamed `WrongLength`. T-MMC3-BG-A12 closes blargg's `4-scanline_timing` (13/13) in the emulator and on the MiSTer core (`PPU_SNAPSHOT_VERSION` 12). Also: the spectator buffer bounded, mapper 45's power-on CHR-AND (two *Famicom Yarou* menus), the mobile apps on the workspace version, and `CI success` that cannot pass untested. The MiSTer bitstreams ship as a release candidate, not hardware-verified; verification moves to v3.x |
| **v2.9.9 "Ballast"** | The release candidate for v3.0.0 -- the tenth release of the v2.9.x line and the last of the line to v3.0.0. All four audit scopes re-run (0 rows regressed; NC-09..17, NF-11..23, NL-11..15, NR-13..17 fixed or decided, NC-17 and NR-14 by the maintainer). T-ORACLE-001 fixed to `4-scanline_timing` sub-test 9: the oracle's `$C001` reload discriminator was the late IRQ; the NESdev rule plus a one-cycle IRQ output deferral replace it (MMC3 section v4). T-MMC5-8X8-SET fixed and wider than the ticket: the MMC5 decodes `$2000`/`$2001` itself and follows the page's CHR table and `$2007` rule, which also fixed *Uchuu Keibitai SDF* (T-COMMERCIAL-GARBLE closed; MMC5 section v6). Audio exact across a save state (APU v5, NL-12). Movie format 4 (NC-10). Mobile save keys migrated to the v2.9.8 identity (NF-21). The sibling's oracle pin moved v2.9.2 -> v2.9.9 with every changed golden attributed, the APU `$4017` / scanline-0 parity in RTL, a frame-sequencer stall found and fixed, and `apu_test` 1-10, `mapper4mmc3irq065` and four checkpoint streams gated. RC bitstreams at seed 6 (261005), byte-identical double compiles. T-GA23C-POWERON searched and left open. |
| **v2.9.8 "Vanguard"** | The preparation release for v3.0.0 -- the ninth release of the v2.9.x line. v3.0.0's planned breaks landed early at the maintainer's direction (ADR 0042/0043 amendments of 2026-10-01; the number stays v2.9.x): `Nes::rom_sha256` hashes the bytes after the header, so earlier saves, cheats and movies are not found; `.rns` epoch 3 and BUS section 2 refuse older states with every legacy reader removed; `.rnm` format 3 and the netplay handshake carry every emulation option (ADR 0044) and Four Score P3/P4; `SystemBus` (was `LockstepBus`), the 2.7.5 deprecations, `serialize_header` and the dead /NMI detector removed, `Header` and `FrameInput` `#[non_exhaustive]`; the Vs. database keyed by the new identity. Every one of 748 staged dumps booted and looked at: fixed from their documentation were the game database rewriting correct NES 2.0 headers (10 games), the iNES 1.0 dirty-tail mapper nibble, mappers 19, 64, 78, 105 (`$4017` inhibit), 153, 191 (non-power-of-two images), 218 and 226, Vs. work RAM and the Goonies palette, and PAL regions now reach iNES 1.0 games. The game database's corrections now reach Android, iOS and libretro; power-on options apply before a game's first frame; Power Cycle keeps the PPU/APU settings; an opt-in Famicom console model; the documented NTSC emphasis model. Performance: the /NMI removal -4.9% to -6.1% on three workloads, three campaign candidates adopted (-1.8% to -4.3% together); end to end only shipped `nestest_fast` is established (-5% to -9% vs v2.9.7), the rest a v2.9.9 lead. MiSTer: aspect/integer-scale/crop/2C03-palette menu options (not seen on hardware), a palette-gate, seed 2 kept after re-sweeps. `cargo test --release --workspace --features test-roms,commercial-roms` 3,367 passed / 0 failed; AccuracyCoin 144/144, nestest 0-diff; co-simulation 175/0/1 on-die, 176/0/1 off-die. **No hardware has run any bitstream.** Plan: `to-dos/plans/v2.9.8-vanguard-plan.md` |
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index d9410d1fb..98757644e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -65,8 +65,8 @@ android {
// `scripts/release-automation/bump_release.py` from now on, starting
// with the 3.0.0 cut. versionCode = MAJOR * 10000 + MINOR * 100 +
// PATCH, so 20909 still rises past 20004.
- versionCode = 30100
- versionName = "3.1.0"
+ versionCode = 30200
+ versionName = "3.2.0"
// No abiFilters here — set per buildType so release ships arm64 only
// while debug keeps x86_64 for the emulator.
// PLAY_BUILD is set per-flavor below (`false` for `foss`, `true` for `play`),
diff --git a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt
index f687261ad..e092404af 100644
--- a/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt
+++ b/android/app/src/main/java/com/doublegate/rustynes/MainActivity.kt
@@ -307,6 +307,18 @@ class MainActivity : AppCompatActivity() {
}
}
+ /** v3.2.0: hold `FLAG_KEEP_SCREEN_ON` while a game runs (set from the
+ * emulation loop on a change of its running state). */
+ fun updateScreenWakeLock(running: Boolean) {
+ runOnUiThread {
+ if (running) {
+ window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+ }
+
override fun onPause() {
super.onPause()
// Stop listening for controller hot-plug while backgrounded.
@@ -450,7 +462,7 @@ class MainActivity : AppCompatActivity() {
private fun onPauseSaveState() {
// v2.7.4 (AND-09): the in-game battery save, synchronously -- the process
// may be killed at any point once it is in the background.
- emulator.flushBattery()
+ emulator.flushBattery(this)
val ctrl = emulator.controller
val sha = emulator.romSha
// RetroAchievements progress sidecar (v1.8.6). A no-op when no RA session /
@@ -558,11 +570,23 @@ class EmulatorHandle {
* land before the controller goes away. A failure is logged and retried by
* the next flush; the file keeps its previous contents.
*/
- fun flushBattery() {
- val saver = battery ?: return
+ fun flushBattery(context: Context) {
+ val ctrl = controller ?: return
+ battery?.let { saver ->
+ runCatching { saver.flushIfChanged { ctrl.batteryRam() } }
+ .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) }
+ }
+ romSha?.let { sha -> FdsSaves.flushIfDirty(context, ctrl, sha) }
+ }
+
+ /**
+ * The session is ending (close, teardown): write the disk one last time and
+ * give up its key, so nothing writes that file until the next load claims
+ * it (v3.2.0, review on #604). Call before clearing [controller].
+ */
+ fun releaseSaves(context: Context) {
val ctrl = controller ?: return
- runCatching { saver.flushIfChanged { ctrl.batteryRam() } }
- .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) }
+ romSha?.let { sha -> FdsSaves.close(context, ctrl, sha) }
}
/** Lowercase hex SHA-256 of the loaded ROM — the save-state directory key. */
@@ -739,6 +763,120 @@ private class PreparedRom(
val batteryNotice: String?,
)
+
+/**
+ * v3.2.0 -- an FDS disk's saved image: `filesDir/fds-saves/.fds.sav`,
+ * the desktop's layout (`EmuCore::bind_fds_save`). FDS games save to the disk
+ * itself, so the whole image is the save.
+ *
+ * The file is keyed by the PRISTINE disk's identity and booted through
+ * [NesController.bootSavedDisk], which keeps that identity. Loading the saved
+ * file as the ROM instead (the first v3.2.0 draft) changed the game's identity
+ * at its first disk write and orphaned its states, RA progress and overrides.
+ */
+internal object FdsSaves {
+ /** The bridge's own ROM size limit; a larger file is not a disk image. */
+ private const val MAX_BYTES = 16L * 1024 * 1024
+
+ private fun file(context: Context, sha: String) =
+ java.io.File(java.io.File(context.filesDir, "fds-saves"), "$sha.fds.sav")
+
+ /** Boot the saved copy if there is one; on any failure keep the pristine disk. */
+ fun bootIfSaved(context: Context, ctrl: NesController, sha: String) {
+ val f = file(context, sha)
+ if (!f.isFile) return
+ runCatching {
+ require(f.length() <= MAX_BYTES) { "saved disk is ${f.length()} bytes" }
+ ctrl.bootSavedDisk(f.readBytes())
+ }.onFailure { android.util.Log.w("RustyNES", "saved FDS disk not used", it) }
+ }
+
+ /**
+ * Disks whose last write failed: flushed again even though now clean. A
+ * concurrent set because the game loop reads it ([needsFlush]) without the
+ * lock a disk write holds.
+ */
+ private val retry: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet()
+
+ /** Which controller may write each key: see [SaveOwners] (review on #604). */
+ private val owners = SaveOwners()
+
+ /**
+ * Whether the periodic flush should call [flushIfDirty]: the disk changed,
+ * or its last write failed. Checking the dirty flag alone left a failed
+ * write unretried until the next lifecycle flush, because [flushIfDirty]
+ * clears the flag before writing (CodeRabbit on #604).
+ */
+ fun needsFlush(ctrl: NesController, sha: String): Boolean = ctrl.diskIsDirty() || sha in retry
+
+ /**
+ * A ROM switch, or the first load: the incoming controller owns its key
+ * from here, and the outgoing one keeps a retiring right to its own until
+ * the new owner first writes it (see [SaveOwners]). No I/O, so call it on
+ * the main thread where the running controller is swapped; then run the
+ * outgoing disk's final write with [finalFlush].
+ */
+ fun transfer(outSha: String?, outCtrl: NesController?, inSha: String?, inCtrl: NesController?) =
+ owners.transfer(outSha, outCtrl, inSha, inCtrl)
+
+ /**
+ * The outgoing controller's last write after [transfer], then its retiring
+ * right is dropped. A no-op once the incoming owner has written the same
+ * key: the disk on file is then newer than this one.
+ */
+ fun finalFlush(context: Context, ctrl: NesController, sha: String) {
+ owners.write(sha, ctrl) { writeIfDirty(context, ctrl, sha) }
+ owners.finish(sha, ctrl)
+ }
+
+ /** Closing a ROM: one last write, then nothing writes [sha] until the next load. */
+ fun close(context: Context, ctrl: NesController, sha: String) =
+ owners.close(sha, ctrl) { writeIfDirty(context, ctrl, sha) }
+
+ /**
+ * Write the disk if the game changed it and [ctrl] may write [sha]. A
+ * refused call changes nothing (the dirty flag stays set).
+ */
+ fun flushIfDirty(context: Context, ctrl: NesController, sha: String) {
+ owners.write(sha, ctrl) { writeIfDirty(context, ctrl, sha) }
+ }
+
+ /**
+ * The write itself, atomically ([android.util.AtomicFile]: a crash
+ * mid-write keeps the previous file). Called only under [owners]' lock.
+ *
+ * The dirty flag is cleared BEFORE the image is copied. `diskIsDirty`,
+ * `diskImageBytes` and `clearDiskDirty` are three separately locked bridge
+ * calls, and the game loop keeps running between them (into `onStop`, and
+ * in picture-in-picture), so clearing after the write discarded any disk
+ * write that landed between the copy and the clear (Cursor on #605). A write
+ * after the clear sets the flag again and is caught by the next flush; a
+ * failed file write is remembered in [retry] and flushed again.
+ */
+ private fun writeIfDirty(context: Context, ctrl: NesController, sha: String) {
+ if (!ctrl.diskIsDirty() && sha !in retry) return
+ ctrl.clearDiskDirty()
+ val bytes = ctrl.diskImageBytes()
+ runCatching {
+ val f = file(context, sha)
+ f.parentFile?.mkdirs()
+ val atomic = android.util.AtomicFile(f)
+ val out = atomic.startWrite()
+ try {
+ out.write(bytes)
+ atomic.finishWrite(out)
+ } catch (e: Throwable) {
+ atomic.failWrite(out)
+ throw e
+ }
+ retry.remove(sha)
+ }.onFailure {
+ retry.add(sha)
+ android.util.Log.w("RustyNES", "FDS disk save failed", it)
+ }
+ }
+}
+
/**
* The I/O half of loading a ROM (v2.7.4, frontend audit AND-04): build the
* controller, key it by SHA-256, load its battery save and the auto-resume
@@ -760,6 +898,10 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri
// (once; never over an existing store; see RomKeyMigration).
val sha = ctrl.romIdentity()
RomKeyMigration.migrate(context.filesDir, sha256Hex(bytes), sha)
+ // v3.2.0: an FDS disk boots the copy it saved last session, under the pristine
+ // disk's identity (so `sha` above keys everything, the save file included).
+ // Before the battery and the auto-resume state below, which are newer.
+ FdsSaves.bootIfSaved(context, ctrl, sha)
// Load the cartridge's `.sav` before its first frame, and before the auto-resume
// state below (a save state is newer and restores cartridge RAM too).
val (battery, batteryNotice) = attachBattery(context, ctrl, sha)
@@ -804,6 +946,7 @@ private fun prepareRom(context: Context, bytes: ByteArray, uri: Uri?, name: Stri
* and never blocks the UI thread.
*/
private fun publishRom(
+ context: Context,
scope: kotlinx.coroutines.CoroutineScope,
emulator: EmulatorHandle,
prepared: PreparedRom,
@@ -811,14 +954,26 @@ private fun publishRom(
): String {
val outgoingCtrl = emulator.controller
val outgoingBattery = emulator.battery
+ val outgoingSha = emulator.romSha
+ // The FDS save keys change hands HERE, on the main thread, before the
+ // running controller is swapped, and with no I/O (review on #604): a claim
+ // made later on an I/O thread left a window in which a close or a pause
+ // was refused, and two quick switches could finish out of order.
+ FdsSaves.transfer(outgoingSha, outgoingCtrl, prepared.sha, prepared.ctrl)
emulator.controller = prepared.ctrl
emulator.romSha = prepared.sha
emulator.romBytes = prepared.bytes
emulator.battery = prepared.battery
- if (outgoingCtrl != null && outgoingBattery != null) {
+ if (outgoingCtrl != null) {
scope.launch(Dispatchers.IO) {
- runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } }
- .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) }
+ if (outgoingBattery != null) {
+ runCatching { outgoingBattery.flushIfChanged { outgoingCtrl.batteryRam() } }
+ .onFailure { android.util.Log.w("RustyNES", "battery save failed", it) }
+ }
+ // The outgoing disk's last write, through its retiring right: a
+ // no-op if the incoming controller has already written the same
+ // key (the same disk, reopened), whose image is then the newer one.
+ if (outgoingSha != null) FdsSaves.finalFlush(context, outgoingCtrl, outgoingSha)
}
}
// Apply this game's remembered video filter (per-game DB), if any.
@@ -1015,6 +1170,11 @@ private class AudioPlayer(sampleRate: Int, private val audioManager: AudioManage
* either. Blocks while the ring is full (paces the loop), exactly like [write].
*/
fun writeBytes(bytes: ByteArray) {
+ // Blocking, deliberately (maintainer, 2026-10-14). A v3.2.0 draft wrote
+ // `write(byte[], ..., WRITE_NON_BLOCKING)`: that overload is for 8/16-bit
+ // PCM, not this PCM_FLOAT track, it dropped any partial write, and it
+ // removed the blocking that clocks this loop to the audio device.
+ // Non-blocking audio needs its own pacing and is a follow-up for a device.
if (focusMuted) return // another app holds audio focus (AND-03)
if (bytes.isNotEmpty()) {
// The samples are little-endian f32 (from `to_le_bytes`); set the buffer
@@ -1348,10 +1508,10 @@ private fun EmulatorScreen(
// Write the running game's battery save first, so reopening
// the same game reads it back with at most the frames run
// during this preparation missing, not up to a second's worth.
- emulator.flushBattery()
+ emulator.flushBattery(context)
prepareRom(context, read(), uri, name())
}
- status = publishRom(scope, emulator, prepared, settings)
+ status = publishRom(context, scope, emulator, prepared, settings)
emulator.showSubScreen = false
mediaTick++
recents = withContext(Dispatchers.IO) { RomLibrary.recents(context) }
@@ -2244,7 +2404,8 @@ private fun EmulatorScreen(
fun closeRom() {
raSaveProgress()
// v2.7.4 (AND-09): the final battery write before the controller goes.
- emulator.flushBattery()
+ emulator.flushBattery(context)
+ emulator.releaseSaves(context)
emulator.battery = null
val ctrl = emulator.controller
if (settings.raEnabled) runCatching { ctrl?.raUnloadGame() }
@@ -2743,6 +2904,8 @@ private fun EmulatorScreen(
// v2.7.4 (AND-09): the battery save is compared once a second (60 frames)
// and written on a change, on Dispatchers.IO so the loop never waits on disk.
var batteryFrame = 0
+ // v3.2.0: an FDS game's disk is checked on the same once-a-second cadence.
+ var fdsFrame = 0
// v2.7.4: host warnings are drained on the same low cadence (see below).
var warnFrame = 0
// v1.8.8 "Atlas" (Workstream E): accumulate frames run with fast-forward
@@ -2751,10 +2914,19 @@ private fun EmulatorScreen(
// default-off PGS_ENABLED gate; the increment auto-unlocks at the Console step
// target. DISTINCT from RetroAchievements.
var turboFrames = 0
+ // v3.2.0: keep the screen on while a game is running (a gamepad player
+ // never touches the screen, so it would otherwise dim and sleep), and let
+ // it sleep again when paused or closed. Only a change calls through.
+ var keepScreenOn: Boolean? = null
try {
while (isActive) {
try {
val ctrl = emulator.controller
+ val running = ctrl != null && !emulator.paused
+ if (running != keepScreenOn) {
+ keepScreenOn = running
+ (context as? MainActivity)?.updateScreenWakeLock(running)
+ }
if (ctrl != null && !romLoaded) {
romLoaded = true
// A fresh controller just appeared: (re-)apply Four Score for the
@@ -2784,6 +2956,25 @@ private fun EmulatorScreen(
}
}
}
+ // v3.2.0: an FDS disk was written only from the lifecycle flush
+ // (onPause/onStop, a ROM switch), so a process killed in the
+ // background lost every disk write since the last one; the
+ // desktop flushes it every frame (Cursor on #605). Once a
+ // second, like the battery above; flushIfDirty is synchronized
+ // and clears the flag first, so a write during it is kept.
+ // `ctrl` was read at the top of this iteration and the key is
+ // read here, so after a ROM switch in between they name two
+ // games; FdsSaves refuses a controller that does not own the
+ // key, so that pair writes nothing (Cursor on #604). A failed
+ // write is retried here too, not only by the next lifecycle
+ // flush (CodeRabbit on #604).
+ val fdsSha = emulator.romSha
+ if (fdsSha != null && ++fdsFrame >= 60) {
+ fdsFrame = 0
+ if (FdsSaves.needsFlush(ctrl, fdsSha)) {
+ launch(Dispatchers.IO) { FdsSaves.flushIfDirty(context, ctrl, fdsSha) }
+ }
+ }
// Advance the turbo/autofire pulse once per emulated frame, then push
// the per-port masks updated below. Cheap no-op when nothing is held
// in turbo. Must run BEFORE the input is latched into the core.
@@ -2867,6 +3058,10 @@ private fun EmulatorScreen(
// Hand the raw RGBA frame to the GPU SurfaceView (opt-in path);
// no-op when the GPU renderer is off.
gpuSurface?.submitFrame(fb)
+ // Always converted, also on the GPU path: screenshots, clips
+ // and the external display read this frame. (A v3.2.0 draft
+ // skipped it when the GPU renderer was on, which left all three
+ // showing a stale frame; not redone without a device to test it.)
packRgbaToArgb(fb, pixels)
}
// Hot path: drain audio as raw bytes (no per-sample Float boxing)
@@ -2987,7 +3182,8 @@ private fun EmulatorScreen(
onDispose {
// Persist RA progress before tearing down the controller (ROM unload).
raSaveProgress()
- emulator.flushBattery()
+ emulator.flushBattery(context)
+ emulator.releaseSaves(context)
emulator.battery = null
emulator.controller = null
}
diff --git a/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt b/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt
index 9adaf5bcf..62f002296 100644
--- a/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt
+++ b/android/app/src/main/java/com/doublegate/rustynes/NesSurfaceView.kt
@@ -145,6 +145,7 @@ class NesSurfaceView(context: Context) : SurfaceView(context), SurfaceHolder.Cal
val w: Worker?
synchronized(lock) {
surfaceGone = true
+ pendingSurface = null
w = worker
w?.running?.set(false)
}
diff --git a/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt b/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt
new file mode 100644
index 000000000..936debd54
--- /dev/null
+++ b/android/app/src/main/java/com/doublegate/rustynes/SaveOwners.kt
@@ -0,0 +1,90 @@
+package com.doublegate.rustynes
+
+/**
+ * Which controller may write each SHA-keyed save file (v3.2.0, used by
+ * [FdsSaves]).
+ *
+ * An FDS disk is flushed from three places -- the game loop once a second, the
+ * lifecycle flush (onPause/onStop, close), and the outgoing flush on a ROM
+ * switch -- on three different threads, and each one names its controller and
+ * its key separately. Serializing the writes was not enough; review on #604
+ * found three ways a write went to the wrong file or was lost:
+ *
+ * - the game loop captured its controller at the top of an iteration and read
+ * the ROM identity later, so a switch in between paired the OUTGOING disk
+ * with the INCOMING game's file (Cursor);
+ * - a flush queued by the outgoing controller could run after the incoming
+ * one had written the same file, when the same disk was reopened, and put
+ * the older image back (CodeRabbit);
+ * - this class's first version claimed the key later, on an I/O thread, so a
+ * close or a pause before the claim was refused and never retried, and two
+ * quick switches could finish out of order and leave a retired controller
+ * owning the key, after which the live session could not save (Cursor).
+ *
+ * So ownership moves in [transfer], which does no I/O and is called on the
+ * main thread in the same step that swaps the running controller: there is no
+ * window in which the running controller does not own its key. The outgoing
+ * controller keeps a RETIRING right to its old key, so its final flush can run
+ * later on an I/O thread, and loses it the moment the new owner first writes
+ * that key: an older image can never replace a newer one. [finish] drops the
+ * retiring right once the final flush has run, so the map does not keep a
+ * finished controller (and its native memory) alive.
+ *
+ * Identity, not equality: controllers are compared with `===`, because two
+ * controllers for the same disk are equal in every way that matters except
+ * which one the game loop is running.
+ */
+internal class SaveOwners {
+ private val owners = HashMap()
+ private val retiring = HashMap()
+
+ /**
+ * Run [block] if [ctrl] owns [key], or is retiring from it and its owner
+ * has not written it since; true when it ran. A write by the owner ends
+ * any retiring right to the key.
+ */
+ @Synchronized
+ fun write(key: String, ctrl: C, block: () -> Unit): Boolean {
+ when {
+ owners[key] === ctrl -> {
+ block()
+ retiring.remove(key)
+ }
+ retiring[key] === ctrl -> block()
+ else -> return false
+ }
+ return true
+ }
+
+ /**
+ * Move [outCtrl] off [outKey] (to a retiring right, if it owned it) and
+ * give [inKey] to [inCtrl]. No I/O: call it where the running controller
+ * is swapped. Either side may be absent; the first load has nothing
+ * outgoing, and a close has nothing incoming.
+ */
+ @Synchronized
+ fun transfer(outKey: String?, outCtrl: C?, inKey: String?, inCtrl: C?) {
+ if (outKey != null && outCtrl != null && owners[outKey] === outCtrl) {
+ owners.remove(outKey)
+ retiring[outKey] = outCtrl
+ }
+ if (inKey != null && inCtrl != null) owners[inKey] = inCtrl
+ }
+
+ /** The final flush of a retired [ctrl] has run: drop its right to [key]. */
+ @Synchronized
+ fun finish(key: String, ctrl: C) {
+ if (retiring[key] === ctrl) retiring.remove(key)
+ }
+
+ /**
+ * Closing [ctrl]'s session: give up [key], run [finalFlush] if it owned it,
+ * and keep nothing. One locked step. A close by a former owner is ignored.
+ */
+ @Synchronized
+ fun close(key: String, ctrl: C, finalFlush: () -> Unit) {
+ if (owners[key] !== ctrl) return
+ owners.remove(key)
+ finalFlush()
+ }
+}
diff --git a/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt b/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt
new file mode 100644
index 000000000..1be6b9e59
--- /dev/null
+++ b/android/app/src/test/java/com/doublegate/rustynes/SaveOwnersTest.kt
@@ -0,0 +1,122 @@
+package com.doublegate.rustynes
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * v3.2.0: the fence that decides which controller may write a SHA-keyed FDS
+ * save. Review on #604 found three ways a write went to the wrong place or
+ * was lost:
+ *
+ * - the game loop paired the controller it captured with the ROM identity it
+ * read later, across a ROM switch, sending the old disk to the new game's
+ * file (Cursor);
+ * - a flush queued by the outgoing controller ran after the incoming one had
+ * written the same file, on reopening the same disk (CodeRabbit);
+ * - the first fix claimed ownership later, on an I/O thread, so a close or a
+ * pause before the claim was refused and never retried, and two quick
+ * switches could install a retired controller as owner (Cursor, on the fix).
+ *
+ * Ownership therefore moves synchronously ([SaveOwners.transfer]), and the
+ * outgoing controller keeps a RETIRING right to its key until the new owner
+ * first writes it.
+ */
+class SaveOwnersTest {
+ private val a = "a".repeat(64)
+ private val b = "b".repeat(64)
+
+ /** A controller that was never given a key cannot write it. */
+ @Test
+ fun only_the_owner_may_write() {
+ val owners = SaveOwners()
+ val ctrl = Any()
+ assertFalse(owners.write(a, ctrl) { error("must not run") })
+ owners.transfer(null, null, a, ctrl)
+ assertTrue(owners.write(a, ctrl) {})
+ }
+
+ /** Cursor's first case: the old controller paired with the NEW game's key. */
+ @Test
+ fun an_outgoing_controller_cannot_write_the_incoming_key() {
+ val owners = SaveOwners()
+ val old = Any()
+ val new = Any()
+ owners.transfer(null, null, a, old)
+ owners.transfer(a, old, b, new)
+ assertFalse(owners.write(b, old) { error("old image into the new game's file") })
+ assertTrue(owners.write(b, new) {})
+ }
+
+ /** The outgoing controller's final flush lands on its own key after the switch. */
+ @Test
+ fun the_outgoing_final_flush_is_allowed_on_its_own_key() {
+ val owners = SaveOwners()
+ val old = Any()
+ val new = Any()
+ owners.transfer(null, null, a, old)
+ owners.transfer(a, old, b, new)
+ assertTrue(owners.write(a, old) {})
+ owners.finish(a, old)
+ assertFalse(owners.write(a, old) { error("finished: nothing may write for it") })
+ }
+
+ /**
+ * CodeRabbit's case: the same disk reopened. The old controller may write
+ * until the new one does, and never after, so an older image can never
+ * replace a newer one.
+ */
+ @Test
+ fun reopening_the_same_key_never_writes_old_over_new() {
+ val owners = SaveOwners()
+ val old = Any()
+ val new = Any()
+ val log = mutableListOf()
+ owners.transfer(null, null, a, old)
+ owners.transfer(a, old, a, new)
+ assertTrue(owners.write(a, old) { log += "old final" })
+ assertTrue(owners.write(a, new) { log += "new" })
+ assertFalse(owners.write(a, old) { log += "old stale" })
+ assertEquals(listOf("old final", "new"), log)
+ }
+
+ /** Cursor's case on the first fix: a close straight after a load still writes. */
+ @Test
+ fun a_close_straight_after_a_load_writes() {
+ val owners = SaveOwners()
+ val ctrl = Any()
+ owners.transfer(null, null, a, ctrl)
+ var wrote = false
+ owners.close(a, ctrl) { wrote = true }
+ assertTrue(wrote)
+ assertFalse(owners.write(a, ctrl) { error("closed") })
+ }
+
+ /** Cursor's other case: two quick switches leave the LIVE controller owning the key. */
+ @Test
+ fun two_quick_switches_leave_the_live_controller_owning() {
+ val owners = SaveOwners()
+ val first = Any()
+ val second = Any()
+ val live = Any()
+ owners.transfer(null, null, a, first)
+ owners.transfer(a, first, a, second)
+ owners.transfer(a, second, a, live)
+ assertTrue(owners.write(a, live) {})
+ assertFalse(owners.write(a, first) { error("long retired") })
+ assertFalse(owners.write(a, second) { error("the live owner has written") })
+ }
+
+ /** A close by a controller that no longer owns the key changes nothing. */
+ @Test
+ fun a_stale_close_is_ignored() {
+ val owners = SaveOwners()
+ val old = Any()
+ val new = Any()
+ owners.transfer(null, null, a, old)
+ owners.transfer(a, old, a, new)
+ owners.close(a, old) {}
+ assertTrue(owners.write(a, new) {})
+ }
+}
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 18e992e86..3e85a5bd6 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -52,7 +52,7 @@ plugins {
// The Compose compiler plugin tracks the Kotlin line AGP builds against, and
// it is the only Kotlin version coordinate this build sets (see the header).
// Moved 2.3.10 -> 2.3.21 within the same line at the v2.6.3 refresh.
- // v2.6.19: 2.3.21 -> 2.4.20 (Dependabot). The constraint people reach for
+ // v2.6.19: 2.3.21 -> 2.4.20, v3.2.0: -> 2.4.21 (both Dependabot). The constraint people reach for
// here -- "the Compose compiler plugin must equal the Kotlin compiler
// version" -- is about Kotlin and is NOT read off AGP's POM coordinate; see
// the header. What the POMs do say, checked directly: each
@@ -68,12 +68,14 @@ plugins {
// back **BUILD SUCCESSFUL in 17m 41s** on run 34694004147 with this plugin
// at 2.4.20 and AGP at 9.4.0 -- both `:app:bundleFossRelease` and
// `:app:bundlePlayRelease` produced their AAB, the Compose mapping tasks
- // ran, and nothing reported a Kotlin/Compose incompatibility.
+ // ran, and nothing reported a Kotlin/Compose incompatibility. At 2.4.21
+ // (AGP 9.4.1) the same job's log reads BUILD SUCCESSFUL in 11m 52s with
+ // both bundle tasks run (run 38351815675, Dependabot #603).
//
// Read that job's LOG, never its check mark: it is `continue-on-error: true`
// (the name says "best-effort packaging"), so a broken Android build leaves
// the PR green. That is the standing hazard here, not this version.
- id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
// v1.8.8 "Atlas" (Workstream J): the Baseline Profile Gradle plugin. The plan
// named 1.4.1, but that stable line predates AGP 9 and its module-type guard
// rejects an AGP-9.x `com.android.application` module ("not a supported android
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
index 5097068a8..9fa7f2882 100644
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 5d7d27a3c..2681c59ce 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,10 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
-# Fetched from services.gradle.org/distributions/gradle-9.8.0-bin.zip.sha256.
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
+# Fetched from services.gradle.org/distributions/gradle-9.8.1-bin.zip.sha256.
# `validateDistributionUrl` only checks the URL is well-formed and reachable;
# this is what actually pins the BYTES.
-distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
+distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/crates/rustynes-apu/src/apu.rs b/crates/rustynes-apu/src/apu.rs
index 5d554e1c2..ed58f1d29 100644
--- a/crates/rustynes-apu/src/apu.rs
+++ b/crates/rustynes-apu/src/apu.rs
@@ -1326,7 +1326,17 @@ impl Apu {
if self.audio_prov.is_some() {
self.record_mix_armed(mixed, external);
}
- self.blip.add_sample(mixed);
+ // The 2A03's own mix is bounded by its DAC tables, so only a mix
+ // with an expansion-audio sample in it needs `sanitize` (v3.2.0;
+ // byte-identical: `add_sample(v)` is `add_sample_bounded(
+ // sanitize(v))`). The value is chosen and the scatter called once,
+ // so its body is inlined here once.
+ let value = if external == 0.0 {
+ mixed
+ } else {
+ BlipBuf::sanitize(mixed)
+ };
+ self.blip.add_sample_bounded(value);
// Nothing follows the general path's `add_sample` but comments --
// the get/put flip moved to `dmc_tick_end` under M-2 -- so there is
// no shared tail to run before returning. Verified by reading it,
diff --git a/crates/rustynes-apu/src/blip.rs b/crates/rustynes-apu/src/blip.rs
index aece2fa43..0a4db3461 100644
--- a/crates/rustynes-apu/src/blip.rs
+++ b/crates/rustynes-apu/src/blip.rs
@@ -253,14 +253,33 @@ impl BlipBuf {
/// limiter.
#[inline]
pub fn add_sample(&mut self, value: f32) {
- // Defensive saturation. Real mixer output never exceeds ~1.5; the
- // clamp catches NaN/Inf from a runaway mapper-audio path so the
- // FIR can't propagate non-finite values through the ring.
- let value = if value.is_finite() {
+ self.add_sample_bounded(Self::sanitize(value));
+ }
+
+ /// The saturation [`Self::add_sample`] applies: a finite value clamped to
+ /// `[-4, 4]`, and 0 for NaN / infinity. Real mixer output never exceeds
+ /// ~1.5; the clamp catches NaN / Inf from a runaway mapper-audio path so
+ /// the FIR cannot propagate non-finite values through the ring.
+ ///
+ /// Separate from the scatter so a caller choosing between a trusted and an
+ /// untrusted input can pick the VALUE and make one call to
+ /// [`Self::add_sample_bounded`] (v3.2.0).
+ #[inline]
+ #[must_use]
+ pub fn sanitize(value: f32) -> f32 {
+ if value.is_finite() {
value.clamp(-4.0, 4.0)
} else {
0.0
- };
+ }
+ }
+
+ /// [`Self::add_sample`] for an input the caller knows is finite and within
+ /// `[-4, 4]` (the 2A03 DAC tables alone): skips the `is_finite` / `clamp`
+ /// of [`Self::sanitize`], whose result it would leave unchanged, so the
+ /// output is byte-identical.
+ #[inline]
+ pub fn add_sample_bounded(&mut self, value: f32) {
let delta = value - self.held_value;
self.held_value = value;
@@ -290,6 +309,14 @@ impl BlipBuf {
// unchanged (`slot += delta * coeff`, one touch per slot, mul
// then add — no FMA contraction), so output is bit-identical
// to the scalar form.
+ //
+ // Measured, keep this shape (v3.2.0): a variant that took ONE
+ // full-length slice when the window does not wrap and the two
+ // runs only when it does made `apu_tick_active_frame_with_external`
+ // 46-48% SLOWER in two A/B runs. That workload scatters on almost
+ // every cycle (its expansion sample changes each cycle), so the
+ // scatter's codegen dominates it. Reverting to this form recovered
+ // it (docs/performance.md, "v3.2.0 APU").
let start = self.head.wrapping_sub(TAPS / 2) & RING_MASK;
let first = (RING_SIZE - start).min(TAPS);
for (slot, &coeff) in self.delta_ring[start..start + first]
diff --git a/crates/rustynes-apu/src/mixer.rs b/crates/rustynes-apu/src/mixer.rs
index 290b14121..dc84831e8 100644
--- a/crates/rustynes-apu/src/mixer.rs
+++ b/crates/rustynes-apu/src/mixer.rs
@@ -125,18 +125,39 @@ impl OnePole {
}
}
- /// Process one sample.
- pub fn process(&mut self, x: f32) -> f32 {
- let y = if self.is_hpf {
- self.coeff * (self.prev_out + x - self.prev_in)
- } else {
- self.prev_out + self.coeff * (x - self.prev_out)
- };
+ /// One sample through the filter as a high-pass:
+ /// `y = coeff * (prev_out + x - prev_in)`. [`Self::process`] picks this or
+ /// [`Self::process_lpf`] from `is_hpf` per call; [`FilterChain`] knows each
+ /// stage's kind and calls the right one directly, which drops that branch
+ /// from every output sample (v3.2.0, byte-identical: the same expression).
+ #[inline]
+ pub fn process_hpf(&mut self, x: f32) -> f32 {
+ let y = self.coeff * (self.prev_out + x - self.prev_in);
+ self.prev_in = x;
+ self.prev_out = y;
+ y
+ }
+
+ /// One sample through the filter as a low-pass:
+ /// `y = prev_out + coeff * (x - prev_out)`. See [`Self::process_hpf`].
+ #[inline]
+ pub fn process_lpf(&mut self, x: f32) -> f32 {
+ let y = self.prev_out + self.coeff * (x - self.prev_out);
self.prev_in = x;
self.prev_out = y;
y
}
+ /// Process one sample, as a high-pass or a low-pass per `is_hpf`.
+ #[inline]
+ pub fn process(&mut self, x: f32) -> f32 {
+ if self.is_hpf {
+ self.process_hpf(x)
+ } else {
+ self.process_lpf(x)
+ }
+ }
+
/// Reset filter state.
pub fn reset(&mut self) {
self.prev_in = 0.0;
@@ -213,11 +234,14 @@ impl FilterChain {
}
}
- /// Process one sample through all three stages.
+ /// Process one sample through all three stages: two high-passes, then the
+ /// low-pass. Each stage's kind is fixed at construction, so this calls
+ /// [`OnePole::process_hpf`] / [`OnePole::process_lpf`] directly.
+ #[inline]
pub fn process(&mut self, x: f32) -> f32 {
- let a = self.hp1.process(x);
- let b = self.hp2.process(a);
- self.lp.process(b)
+ let a = self.hp1.process_hpf(x);
+ let b = self.hp2.process_hpf(a);
+ self.lp.process_lpf(b)
}
/// Reset filter state.
diff --git a/crates/rustynes-apu/src/noise.rs b/crates/rustynes-apu/src/noise.rs
index 419f56d81..87dfd7541 100644
--- a/crates/rustynes-apu/src/noise.rs
+++ b/crates/rustynes-apu/src/noise.rs
@@ -2,8 +2,9 @@
//!
//! Per `docs/apu-2a03.md` §Behavior and NESdev wiki "APU Noise" page.
//!
-//! - Mode 0 (long): feedback = bit 0 XOR bit 1, 15-bit period.
-//! - Mode 1 (short): feedback = bit 0 XOR bit 6, 93-bit period.
+//! - Mode 0 (long): feedback = bit 0 XOR bit 1, a 32767-step period.
+//! - Mode 1 (short): feedback = bit 0 XOR bit 6, a 93- or 31-step period
+//! (which one depends on the register's state when the mode is set).
use crate::Region;
use crate::envelope::Envelope;
@@ -24,7 +25,9 @@ pub const PAL_NOISE_PERIODS: [u16; 16] = [
pub struct Noise {
/// LFSR (initialized to 1 on power-up; only bottom 15 bits used).
pub(crate) lfsr: u16,
- /// Mode (false = long / 15-bit, true = short / 6-bit).
+ /// Mode: false = long (feedback from bit 1, 32767 steps), true = short
+ /// (feedback from bit 6, 93 or 31 steps depending on where the register
+ /// was when the mode was set; NESdev wiki "APU Noise").
pub(crate) mode: bool,
/// Timer reload (from period table).
pub(crate) timer_period: u16,
diff --git a/crates/rustynes-apu/src/pulse.rs b/crates/rustynes-apu/src/pulse.rs
index 1b6719c8c..708bba996 100644
--- a/crates/rustynes-apu/src/pulse.rs
+++ b/crates/rustynes-apu/src/pulse.rs
@@ -25,7 +25,9 @@ const DUTY_TABLE: [[u8; 8]; 4] = [
pub struct Pulse {
/// Duty selection (0..=3).
pub(crate) duty: u8,
- /// Step index into the duty table (0..=7). Decremented on each timer underflow.
+ /// Step index into the duty table (0..=7), advanced on each timer underflow.
+ /// The 2A03's sequencer counts DOWN from zero (NESdev wiki "APU Pulse");
+ /// `DUTY_TABLE` lists its outputs in time order, so the index counts up.
pub(crate) step: u8,
/// 11-bit timer reload (from `$4002`/`$4003` low+high writes).
pub(crate) timer_period: u16,
@@ -125,7 +127,8 @@ impl Pulse {
pub fn clock_timer(&mut self) {
if self.timer == 0 {
self.timer = self.timer_period;
- // 8-step duty sequencer, decremented (NESdev wiki).
+ // 8-step duty sequencer (NESdev wiki). The hardware counter runs
+ // down; `DUTY_TABLE` is in time order, so the index runs up.
self.step = (self.step + 1) & 0x07;
} else {
self.timer -= 1;
@@ -188,8 +191,8 @@ impl Pulse {
#[must_use]
pub fn output(&self) -> u8 {
if self.length.count == 0
- || self.muted()
|| DUTY_TABLE[self.duty as usize][self.step as usize] == 0
+ || self.muted()
{
0
} else {
diff --git a/crates/rustynes-cheevos/src/client.rs b/crates/rustynes-cheevos/src/client.rs
index 2dc6b9ad1..a1dc5edf6 100644
--- a/crates/rustynes-cheevos/src/client.rs
+++ b/crates/rustynes-cheevos/src/client.rs
@@ -275,7 +275,7 @@ pub struct RaGameSummary {
}
/// A safe, owned snapshot of the logged-in user (subset of `rc_client_user_t`).
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Clone, PartialEq, Eq)]
pub struct RaUser {
/// The name shown to other users.
pub display_name: String,
@@ -289,6 +289,18 @@ pub struct RaUser {
pub score_softcore: u32,
}
+impl std::fmt::Debug for RaUser {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("RaUser")
+ .field("display_name", &self.display_name)
+ .field("username", &self.username)
+ .field("token", &"")
+ .field("score", &self.score)
+ .field("score_softcore", &self.score_softcore)
+ .finish()
+ }
+}
+
// ---------------------------------------------------------------------------
// RaClient
// ---------------------------------------------------------------------------
@@ -745,3 +757,31 @@ impl Drop for RaClient {
// `transport` is dropped after this, joining the worker thread.
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::RaUser;
+
+ /// v3.2.0: the login token never reaches a `Debug` rendering, so logging a
+ /// user (or a struct holding one) cannot leak the credential.
+ #[test]
+ fn a_users_debug_output_redacts_the_token() {
+ let user = RaUser {
+ display_name: "Shown".into(),
+ username: "acct".into(),
+ token: "s3cr3t-token-value".into(),
+ score: 10,
+ score_softcore: 20,
+ };
+ let shown = format!("{user:?}");
+ assert!(
+ !shown.contains("s3cr3t-token-value"),
+ "token leaked: {shown}"
+ );
+ assert!(shown.contains(""));
+ assert!(
+ shown.contains("acct") && shown.contains("Shown"),
+ "the rest is kept"
+ );
+ }
+}
diff --git a/crates/rustynes-cheevos/src/ffi.rs b/crates/rustynes-cheevos/src/ffi.rs
index 1ba99f1d8..a9ce0af31 100644
--- a/crates/rustynes-cheevos/src/ffi.rs
+++ b/crates/rustynes-cheevos/src/ffi.rs
@@ -50,8 +50,9 @@ pub const RC_OK: c_int = 0;
pub type rc_client_read_memory_func_t =
extern "C" fn(address: u32, buffer: *mut u8, num_bytes: u32, client: *mut rc_client_t) -> u32;
-pub type rc_client_server_callback_t =
- extern "C" fn(server_response: *const rc_api_server_response_t, callback_data: *mut c_void);
+pub type rc_client_server_callback_t = Option<
+ extern "C" fn(server_response: *const rc_api_server_response_t, callback_data: *mut c_void),
+>;
pub type rc_client_server_call_t = extern "C" fn(
request: *const rc_api_request_t,
diff --git a/crates/rustynes-cheevos/src/http.rs b/crates/rustynes-cheevos/src/http.rs
index b3ed5e321..54030d813 100644
--- a/crates/rustynes-cheevos/src/http.rs
+++ b/crates/rustynes-cheevos/src/http.rs
@@ -96,11 +96,21 @@ impl HttpTransport {
while let Ok(done) = self.completion_rx.try_recv() {
// Rebuild the C callback pointer + data from the carried bits.
let cb: ffi::rc_client_server_callback_t = {
- // SAFETY: `done.callback` is the exact pointer rcheevos handed
- // to the trampoline; transmuting the bits back is sound and it
- // is invoked here on the main thread.
- unsafe {
- std::mem::transmute::(done.callback)
+ // rcheevos may pass a NULL completion callback; the trampoline
+ // carried it as 0 (v3.2.0, the callback type is an `Option`),
+ // and it is skipped below instead of called.
+ if done.callback == 0 {
+ None
+ } else {
+ // SAFETY: a non-zero `done.callback` is the bits of the exact
+ // non-null `extern "C" fn` rcheevos handed the trampoline
+ // (`server_call_trampoline`), so transmuting them back yields
+ // that pointer; it is invoked here, on the main thread.
+ unsafe {
+ std::mem::transmute::(
+ done.callback,
+ )
+ }
}
};
let callback_data = done.callback_data as *mut c_void;
@@ -112,7 +122,9 @@ impl HttpTransport {
};
// SAFETY: `cb` is a valid rcheevos completion callback; `response`
// borrows `done.body` which outlives this call.
- cb(&raw const response, callback_data);
+ if let Some(cb_fn) = cb {
+ cb_fn(&raw const response, callback_data);
+ }
}
}
}
@@ -257,7 +269,7 @@ pub extern "C" fn server_call_trampoline(
Some(cstr.to_bytes().to_vec())
};
- let cb_bits = callback as usize;
+ let cb_bits = callback.map_or(0, |f| f as usize);
let data_bits = callback_data as usize;
crate::client::with_transport(|t| {
diff --git a/crates/rustynes-core/src/bus.rs b/crates/rustynes-core/src/bus.rs
index 7fc22ddfd..6c11bf72d 100644
--- a/crates/rustynes-core/src/bus.rs
+++ b/crates/rustynes-core/src/bus.rs
@@ -345,6 +345,11 @@ pub struct SystemBus {
/// `notify_frame_event` / `irq_pending`) on boards that don't use
/// them. Constant per mapper type; NOT part of the save-state.
mapper_caps: rustynes_mappers::MapperCaps,
+ /// v3.2.0 — [`Mapper::may_unmap_prg_rom`], cached beside
+ /// [`Self::mapper_caps`] with the same refresh points. When `false` the
+ /// `$8000-$FFFF` read path skips the per-read `cpu_read_unmapped`
+ /// dispatch. Constant per mapper type; NOT part of the save-state.
+ prg_may_unmap: bool,
/// The original iNES/NES-2.0 ROM bytes, kept so [`Self::power_cycle`] can
/// rebuild the mapper to a true power-on state (fresh bank registers,
/// cleared CHR-RAM + volatile PRG-RAM). `None` on the FDS path (which has
@@ -949,6 +954,7 @@ impl SystemBus {
// v2.8.0 Phase 4 — cache the capability flags once (constant per
// mapper type); the per-cycle hot loop reads the copy.
let mapper_caps = mapper.caps();
+ let prg_may_unmap = mapper.may_unmap_prg_rom();
let mut bus = Self {
ram: fresh_ram(),
ppu: Ppu::new(region),
@@ -956,6 +962,7 @@ impl SystemBus {
cart,
mapper,
mapper_caps,
+ prg_may_unmap,
// Set by `with_sample_rate` (iNES path); stays `None` for FDS.
rom_bytes: None,
controllers: [Controller::new(); 2],
@@ -1295,6 +1302,7 @@ impl SystemBus {
// fresh mapper instance (same type, same flags, but keep
// the invariant mechanical).
self.mapper_caps = self.mapper.caps();
+ self.prg_may_unmap = self.mapper.may_unmap_prg_rom();
// v3.1.0: a fresh board starts on its header's MMC3 revision;
// the override is configuration and carries over.
if self.mmc3_revision_override.is_some() {
@@ -1758,8 +1766,10 @@ impl SystemBus {
0x4000..=0x4014 | 0x4018..=0x401F => self.open_bus,
0x4020..=0xFFFF => {
// Mirror the production read path so the debugger hex viewer
- // shows the Game-Genie-substituted byte the CPU would see.
- let raw = self.mapper.cpu_read(addr);
+ // shows the Game-Genie-substituted byte the CPU would see,
+ // through `cpu_peek` so a board whose read consumes state
+ // keeps it (v3.2.0: the FFE trainer boot).
+ let raw = self.mapper.cpu_peek(addr);
self.apply_genie(addr, raw)
}
}
@@ -1772,21 +1782,14 @@ impl SystemBus {
pub fn debug_peek_ppu(&mut self, addr: u16) -> u8 {
let addr = addr & 0x3FFF;
match addr {
- // v3.1.0: on the five boards whose CHR read changes them (MMC2 and
- // MMC4 latches, the J.Y. ASIC's read-clocked IRQ, Bandai 96 and
- // Nanjing 163; `Mapper::chr_reads_are_pure`) the read is bracketed
- // by the board's own save / load, so a debugger panel or an HD-pack
- // tile hash no longer changes the game. Until v3.1.0 opening the
- // pattern viewer on Punch-Out!! flipped its CHR latch. Free on every
- // other board.
- 0x0000..=0x1FFF if self.mapper.chr_reads_are_pure() => self.mapper.ppu_read(addr),
- 0x0000..=0x1FFF => {
- let saved = self.mapper.save_state();
- let v = self.mapper.ppu_read(addr);
- let restored = self.mapper.load_state(&saved);
- debug_assert!(restored.is_ok(), "a board must reload its own state");
- v
- }
+ // Side-effect-free on every board: a debugger panel or an HD-pack
+ // tile hash must never change the game (until v3.1.0 opening the
+ // pattern viewer on Punch-Out!! flipped its CHR latch). Since v3.2.0
+ // that is `Mapper::ppu_peek`'s contract -- exactly the byte
+ // `ppu_read` would return, with no state change -- checked for every
+ // board by `ppu_peek_matches_a_side_effect_free_ppu_read`, instead of
+ // a save / `ppu_read` / restore around impure boards here.
+ 0x0000..=0x1FFF => self.mapper.ppu_peek(addr),
0x2000..=0x3EFF => {
let addr = if addr >= 0x3000 && !self.mapper.nametable_unfolded() {
addr - 0x1000
@@ -2696,6 +2699,24 @@ impl SystemBus {
self.mapper.set_disk_side(side);
}
+ /// Whether the cartridge has a barcode reader (Datach, mapper 157).
+ #[must_use]
+ pub fn has_barcode_reader(&self) -> bool {
+ self.mapper.has_barcode_reader()
+ }
+
+ /// Present a validated barcode to the cartridge's reader; `false` when
+ /// the cartridge has none.
+ pub fn scan_barcode(&mut self, code: &rustynes_mappers::Barcode) -> bool {
+ self.mapper.scan_barcode(code)
+ }
+
+ /// Whether a barcode scan is still being presented.
+ #[must_use]
+ pub fn barcode_scan_in_progress(&self) -> bool {
+ self.mapper.barcode_scan_in_progress()
+ }
+
/// Number of selectable NSF songs (0 for cartridge / disk builds).
#[must_use]
pub fn nsf_song_count(&self) -> u8 {
@@ -3798,7 +3819,17 @@ impl SystemBus {
self.vs_overlay_4017(base)
}
0x4020..=0xFFFF => {
- if self.mapper.cpu_read_unmapped(addr) {
+ // `$8000-$FFFF` asks only a board that can leave it undriven
+ // (`prg_may_unmap`, v3.2.0): PRG-ROM fetches are the hottest
+ // reads, and almost every board always drives them. A v3.2.0
+ // import skipped the question above `$8000` for EVERY board,
+ // which read ROM where 228 / 235 float the bus
+ // (`boards_that_unmap_prg_rom_read_open_bus_above_8000`).
+ debug_assert!(
+ addr < 0x8000 || self.prg_may_unmap || !self.mapper.cpu_read_unmapped(addr),
+ "mapper unmaps ${addr:04X} but does not declare may_unmap_prg_rom"
+ );
+ if (addr < 0x8000 || self.prg_may_unmap) && self.mapper.cpu_read_unmapped(addr) {
// Unmapped read: nothing drives the bus, so the CPU sees
// the floating-latch value (nesdev "Open bus behavior":
// "all it sees on its data inputs is whatever was left to
@@ -5229,6 +5260,54 @@ mod four_score_tests {
);
}
+ /// iNES image for mapper `mapper` with `prg_16k` x 16 KiB of PRG filled
+ /// with `$AA` and CHR-RAM.
+ fn prg_filled_rom(mapper: u8, prg_16k: u8) -> Vec {
+ let mut rom = Vec::new();
+ rom.extend_from_slice(b"NES\x1A");
+ rom.push(prg_16k);
+ rom.push(0); // CHR-RAM
+ rom.push((mapper & 0x0F) << 4);
+ rom.push(mapper & 0xF0);
+ rom.extend_from_slice(&[0u8; 8]);
+ rom.resize(16 + usize::from(prg_16k) * 0x4000, 0xAA);
+ rom
+ }
+
+ /// Two boards can leave `$8000-$FFFF` undriven: *Action 52* (mapper 228)
+ /// with its absent third chip selected, and the Golden Game multicart
+ /// (mapper 235) with an empty socket selected. Both must read open bus
+ /// there, not ROM. Regression for a v3.2.0 import that consulted
+ /// `cpu_read_unmapped` only below `$8000` (a hot-path shortcut), which made
+ /// both read the ROM byte (`$AA` here) instead of the floating `$5A`.
+ #[test]
+ fn boards_that_unmap_prg_rom_read_open_bus_above_8000() {
+ // 228: 1.5 MiB = chips 0, 1, 3. `$9000` selects chip 2 (addr bits
+ // 12-11 = 0b10), the one the image does not carry.
+ let mut bus = SystemBus::new(&prg_filled_rom(228, 96)).expect("228 parses");
+ assert_eq!(bus.raw_cpu_read(0x8000), 0xAA, "fixture: chip 0 is ROM");
+ bus.mapper.cpu_write(0x9000, 0x00);
+ bus.ram[0] = 0x5A;
+ assert_eq!(bus.raw_cpu_read(0x0000), 0x5A);
+ assert_eq!(
+ bus.raw_cpu_read(0x8000),
+ 0x5A,
+ "228 absent chip floats the bus"
+ );
+
+ // 235: a 1 MiB image fills socket 0 only; `$8100` selects socket 1.
+ let mut bus = SystemBus::new(&prg_filled_rom(235, 64)).expect("235 parses");
+ assert_eq!(bus.raw_cpu_read(0x8000), 0xAA, "fixture: socket 0 is ROM");
+ bus.mapper.cpu_write(0x8100, 0x00);
+ bus.ram[0] = 0x5A;
+ assert_eq!(bus.raw_cpu_read(0x0000), 0x5A);
+ assert_eq!(
+ bus.raw_cpu_read(0xC000),
+ 0x5A,
+ "235 empty socket floats the bus"
+ );
+ }
+
#[test]
fn internal_data_bus_round_trips_through_save_state() {
// v2.8.0 (libretro audit §2.4). The 2A03's internal data bus is a
diff --git a/crates/rustynes-core/src/hardware_options.rs b/crates/rustynes-core/src/hardware_options.rs
index 95467e71a..cbc30051a 100644
--- a/crates/rustynes-core/src/hardware_options.rs
+++ b/crates/rustynes-core/src/hardware_options.rs
@@ -96,7 +96,8 @@ const MAX_GENIE_CODES: usize = u8::MAX as usize;
/// | 1 | v3.0.0 | the MMC3 background-at-`$1000` A12 rule (T-MMC3-BG-A12) |
/// | 2 | v3.0.1 | mapper 45 CHR-RAM unbanked (T-GA23C-CHRRAM; *Famicom Yarou Vol.1*) |
/// | 3 | v3.1.0 | a DMC load DMA refused by a write takes four cycles; sprite evaluation starts at OAMADDR as of dot 65 and keeps a misaligned OAMADDR when X is in range (AccuracyCoin re-sync to `f5f41dc2`) |
-pub const EMULATION_EPOCH: u32 = 3;
+/// | 4 | v3.2.0 | mapper 290's 32 KiB PRG mode maps both halves in order (*Asder 20-in-1* boots); a 24C02 keeps its word address across a START (mapper 16, 157); VRC4's IRQ mode bit reads 1 = cycle, 0 = scanline (mappers 21, 23, 25); VRC2 mirroring ignores bit 1, and 23 / 25 submapper 3 are built as VRC2; mapper 71 submapper 0 detects *Fire Hawk*'s `$9000` single-screen register |
+pub const EMULATION_EPOCH: u32 = 4;
/// Every host-settable option that changes what the emulated console does.
///
diff --git a/crates/rustynes-core/src/lib.rs b/crates/rustynes-core/src/lib.rs
index 73073b81c..a82f3cb60 100644
--- a/crates/rustynes-core/src/lib.rs
+++ b/crates/rustynes-core/src/lib.rs
@@ -98,14 +98,15 @@ pub use movie::{
#[cfg(feature = "debug-hooks")]
pub use nes::TraceRec;
pub use nes::{
- ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL, MAX_CPU_OVERCLOCK,
- MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam,
+ BarcodeScanError, ConsoleModel, FRAME_DURATION_DENDY, FRAME_DURATION_NTSC, FRAME_DURATION_PAL,
+ MAX_CPU_OVERCLOCK, MAX_EXTRA_SCANLINES, Nes, PowerOnConfig, PowerOnRam,
};
// v2.1.7 P5 — re-export the PPU-side hardware-revision knobs at the core surface
// so downstream consumers (frontend, test-harness) depend on `rustynes-core`.
pub use rewind::{
REWIND_DEFAULT_KEYFRAME_PERIOD, REWIND_DEFAULT_MAX_BYTES, RewindError, RewindRing,
};
+pub use rustynes_mappers::{Barcode, BarcodeError};
pub use rustynes_ppu::{PaletteInit, PpuRevision};
pub use save_state::{
BinReader, BinWriter, FORMAT_VERSION, HEADER_LEN, Header, MAGIC, MIN_FORMAT_VERSION,
diff --git a/crates/rustynes-core/src/nes.rs b/crates/rustynes-core/src/nes.rs
index c578e72a9..c25530078 100644
--- a/crates/rustynes-core/src/nes.rs
+++ b/crates/rustynes-core/src/nes.rs
@@ -151,6 +151,18 @@ pub struct TraceRec {
pub cycle: u64,
}
+/// Why [`Nes::scan_barcode`] refused a scan.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
+pub enum BarcodeScanError {
+ /// The string is not a valid 13- or 8-digit JAN / EAN code.
+ #[error("not a valid JAN/EAN barcode: {0}")]
+ Malformed(rustynes_mappers::BarcodeError),
+ /// The cartridge has no barcode reader (only the Datach, mapper 157,
+ /// does).
+ #[error("this cartridge has no barcode reader")]
+ NoReader,
+}
+
/// The largest extra-scanline overclock the core accepts.
///
/// [`Nes::set_extra_scanlines`] clamps to it, and a movie's options record
@@ -344,6 +356,19 @@ impl Nes {
self.bus.mapper.clear_save_data();
}
+ /// Whether the cartridge's save data may have changed since the last
+ /// [`Self::clear_save_data_dirty`]; `None` when the board does not track it
+ /// (the caller then compares bytes). See `Mapper::is_save_data_dirty`.
+ #[must_use]
+ pub fn is_save_data_dirty(&self) -> Option {
+ self.bus.mapper.is_save_data_dirty()
+ }
+
+ /// Mark the cartridge's save data clean. See `Mapper::clear_save_data_dirty`.
+ pub fn clear_save_data_dirty(&mut self) {
+ self.bus.mapper.clear_save_data_dirty();
+ }
+
/// v2.7.3 — whether the cartridge has non-volatile save data: the header
/// declares battery-backed PRG-RAM (iNES flags 6 bit 1), or (v2.9.6) the
/// board is self-flashable and its flash is the save (GTROM, mapper 111,
@@ -1691,6 +1716,49 @@ impl Nes {
self.bus.set_disk_side(side);
}
+ // --- Datach barcode reader (mapper 157, v3.2.0) ---
+
+ /// Whether the cartridge has a barcode reader. Only the Bandai Datach
+ /// Joint ROM System (mapper 157) does; a frontend shows its "scan a
+ /// barcode" control only when this is `true`.
+ #[must_use]
+ pub fn has_barcode_reader(&self) -> bool {
+ self.bus.has_barcode_reader()
+ }
+
+ /// Swipe a barcode past the Datach reader.
+ ///
+ /// `code` is a JAN / EAN code of 13 or 8 digits (spaces and hyphens are
+ /// skipped), validated here at the boundary, check digit included. The
+ /// reader then presents the symbol's bars to the CPU over the following
+ /// CPU cycles, deterministically: the same scan at the same cycle yields
+ /// the same frames, and a save state taken mid-scan restores mid-scan.
+ /// A scan replaces any scan still in flight.
+ ///
+ /// A scan is an input that changes emulation, and it is **not** recorded
+ /// by movies or carried by netplay: callers running either must refuse
+ /// it rather than desynchronise (the desktop frontend does).
+ ///
+ /// # Errors
+ ///
+ /// [`BarcodeScanError::Malformed`] for a string that is not a valid
+ /// 13- or 8-digit code, and [`BarcodeScanError::NoReader`] when the
+ /// cartridge has no barcode reader.
+ pub fn scan_barcode(&mut self, code: &str) -> Result<(), BarcodeScanError> {
+ let code = rustynes_mappers::Barcode::parse(code).map_err(BarcodeScanError::Malformed)?;
+ if self.bus.scan_barcode(&code) {
+ Ok(())
+ } else {
+ Err(BarcodeScanError::NoReader)
+ }
+ }
+
+ /// Whether a barcode scan is still being presented to the CPU.
+ #[must_use]
+ pub fn barcode_scan_in_progress(&self) -> bool {
+ self.bus.barcode_scan_in_progress()
+ }
+
/// Start recording the diagnostic FDS read-stream trace (the `$4031` disk-byte
/// stream + `$4025` control writes + side changes). Off by default and
/// observation-only — it never affects emulation, so the determinism contract
@@ -3488,6 +3556,121 @@ mod tests {
bytes
}
+ /// Synthetic MMC5 (mapper 5) image, 32 KiB PRG + 8 KiB CHR. MMC5 powers on
+ /// in PRG mode 3 with the last 8 KiB bank at `$E000`, so the program lives
+ /// there. It waits two vblanks (past the NES PPU warm-up, where a
+ /// `$2001` write is ignored), turns rendering on, arms the scanline IRQ
+ /// at line 20 (`$5203` / `$5204`) and idles with interrupts enabled. The
+ /// IRQ handler counts into `$0000` and acknowledges by reading `$5204`.
+ fn synth_mmc5_scanline_irq() -> Vec {
+ let mut bytes = Vec::with_capacity(16 + 32 * 1024 + 8 * 1024);
+ bytes.extend_from_slice(b"NES\x1A");
+ bytes.push(2); // 2 x 16 KiB PRG
+ bytes.push(1); // 1 x 8 KiB CHR
+ bytes.push(0x50); // flags6: mapper low nibble 5
+ bytes.push(0); // flags7
+ bytes.extend_from_slice(&[0u8; 8]);
+ let mut prg = vec![0u8; 32 * 1024];
+ let base = 0x6000; // $E000 within the last 8 KiB bank
+ #[rustfmt::skip]
+ let reset: [u8; 35] = [
+ 0x78, // $E000 SEI
+ 0xA2, 0xFF, // $E001 LDX #$FF
+ 0x9A, // $E003 TXS
+ 0xA9, 0x40, // $E004 LDA #$40
+ 0x8D, 0x17, 0x40, // $E006 STA $4017 (inhibit the APU frame IRQ)
+ 0x2C, 0x02, 0x20, // $E009 BIT $2002
+ 0x10, 0xFB, // $E00C BPL $E009
+ 0x2C, 0x02, 0x20, // $E00E BIT $2002
+ 0x10, 0xFB, // $E011 BPL $E00E
+ 0xA9, 0x1E, // $E013 LDA #$1E
+ 0x8D, 0x01, 0x20, // $E015 STA $2001 (rendering on)
+ 0xA9, 0x14, // $E018 LDA #20
+ 0x8D, 0x03, 0x52, // $E01A STA $5203 (compare = line 20)
+ 0xA9, 0x80, // $E01D LDA #$80
+ 0x8D, 0x04, 0x52, // $E01F STA $5204 (IRQ enable)
+ 0x58, // $E022 CLI
+ ];
+ prg[base..base + reset.len()].copy_from_slice(&reset);
+ // $E023: JMP $E023 (idle)
+ prg[base + 0x23..base + 0x26].copy_from_slice(&[0x4C, 0x23, 0xE0]);
+ // $E030: IRQ handler -- INC $00 ; LDA $5204 (ack) ; RTI
+ prg[base + 0x30..base + 0x36].copy_from_slice(&[0xE6, 0x00, 0xAD, 0x04, 0x52, 0x40]);
+ // $E040: NMI handler -- RTI (NMI is never enabled; defensive)
+ prg[base + 0x40] = 0x40;
+ let len = prg.len();
+ prg[len - 6..].copy_from_slice(&[0x40, 0xE0, 0x00, 0xE0, 0x30, 0xE0]);
+ bytes.extend_from_slice(&prg);
+ bytes.extend_from_slice(&[0u8; 8 * 1024]);
+ bytes
+ }
+
+ /// The MMC5 scanline IRQ fires through the real `SystemBus`, once per
+ /// rendered frame. Regression for a v3.2.0 import that moved the
+ /// scanline detector into `Mmc5::ppu_read` (a nametable-snoop model):
+ /// the lockstep bus fetches nametables through `peek_nametable`, never
+ /// `ppu_read`, so the detector never saw a fetch and the IRQ stopped
+ /// firing in real play (*Castlevania III*, *Uchuu Keibitai SDF* and
+ /// *Bandit Kings* all went red). The `mmc5` test-ROM gate did not catch
+ /// it, because none of those ROMs depends on the scanline IRQ.
+ #[test]
+ fn mmc5_scanline_irq_fires_through_the_real_bus() {
+ let mut nes = Nes::from_rom(&synth_mmc5_scanline_irq()).unwrap();
+ for _ in 0..10 {
+ nes.run_frame();
+ }
+ let count = nes.peek(0x0000);
+ // Rendering is on from about frame 2; one IRQ per frame after that.
+ assert!(
+ (5..=10).contains(&count),
+ "expected one MMC5 scanline IRQ per rendered frame, counted {count}"
+ );
+ }
+
+ /// `Nes::scan_barcode` validates at the boundary, refuses a cartridge
+ /// with no reader, and a scan is part of the save state: a snapshot taken
+ /// mid-swipe restores mid-swipe and the two runs stay identical.
+ #[test]
+ fn datach_barcode_scan_is_validated_and_survives_a_snapshot() {
+ let mut nrom = Nes::from_rom(&synth_nrom(32, 8)).unwrap();
+ assert!(!nrom.has_barcode_reader());
+ assert_eq!(
+ nrom.scan_barcode("4006381333931"),
+ Err(BarcodeScanError::NoReader)
+ );
+
+ let rom = synth_board_rom(157, SweepHeader::Nes2 { submapper: 0 }, 32, 0);
+ let mut nes = Nes::from_rom(&rom).unwrap();
+ assert!(nes.has_barcode_reader());
+ assert_eq!(
+ nes.scan_barcode("4006381333932"),
+ Err(BarcodeScanError::Malformed(
+ rustynes_mappers::BarcodeError::BadCheckDigit {
+ expected: 1,
+ found: 2
+ }
+ ))
+ );
+ assert!(
+ !nes.barcode_scan_in_progress(),
+ "a refused scan starts nothing"
+ );
+ assert_eq!(nes.scan_barcode("4006381333931"), Ok(()));
+ assert!(nes.barcode_scan_in_progress());
+ nes.run_frame();
+ assert!(nes.barcode_scan_in_progress(), "a swipe outlasts a frame");
+ let mid = nes.snapshot();
+ let mut other = Nes::from_rom(&rom).unwrap();
+ other.restore(&mid).unwrap();
+ assert!(other.barcode_scan_in_progress());
+ for _ in 0..20 {
+ nes.run_frame();
+ other.run_frame();
+ }
+ assert!(!nes.barcode_scan_in_progress(), "the swipe ends");
+ assert_eq!(nes.snapshot(), other.snapshot());
+ }
+
/// Synthetic NES 2.0 NROM with console type Vs. System and a byte-13 Vs.
/// PPU type (low nibble).
fn synth_vs_nrom(vs_ppu_low_nibble: u8) -> Vec {
@@ -3757,6 +3940,32 @@ mod tests {
assert!(nes.add_genie_code("BADCODE!").is_err());
}
+ #[test]
+ fn debug_peek_cpu_leaves_an_ffe_trainer_boot_armed() {
+ // Mapper 6 (FFE) with a trainer boots through a one-shot stub at
+ // $7200 that the CPU's own reads retire. The debugger, disassembler
+ // and Lua read through `debug_peek_cpu`, which must reach the board's
+ // `cpu_peek`: through `cpu_read` it retired the stub, and the game
+ // then never ran its trainer (agy on #604).
+ let mut rom = Vec::new();
+ rom.extend_from_slice(b"NES\x1A");
+ rom.extend_from_slice(&[16, 0, 0x64, 0x00]); // 256 KiB PRG, mapper 6, trainer
+ rom.extend_from_slice(&[0u8; 8]);
+ let mut trainer = vec![0u8; 512];
+ trainer[0] = 0x4C; // JMP, at $7000
+ rom.extend_from_slice(&trainer);
+ rom.extend_from_slice(&vec![0u8; 256 * 1024]);
+ let mut nes = Nes::from_rom(&rom).expect("synthetic FFE ROM parses");
+ let stub: Vec = (0..6u16)
+ .map(|i| nes.bus_mut().debug_peek_cpu(0x7200 + i))
+ .collect();
+ assert_eq!(stub[0], 0x20, "the stub begins JSR $7003");
+ let again: Vec = (0..6u16)
+ .map(|i| nes.bus_mut().debug_peek_cpu(0x7200 + i))
+ .collect();
+ assert_eq!(again, stub, "a debugger peek retired the boot stub");
+ }
+
#[test]
fn poke_ram_writes_system_ram_and_ignores_rom() {
let rom = synth_nrom(16, 8);
diff --git a/crates/rustynes-cosim/Cargo.lock b/crates/rustynes-cosim/Cargo.lock
index 03fa409ad..41d74a111 100644
--- a/crates/rustynes-cosim/Cargo.lock
+++ b/crates/rustynes-cosim/Cargo.lock
@@ -98,7 +98,7 @@ dependencies = [
[[package]]
name = "rustynes-apu"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags",
"libm",
@@ -107,7 +107,7 @@ dependencies = [
[[package]]
name = "rustynes-core"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags",
"lz4_flex",
@@ -121,7 +121,7 @@ dependencies = [
[[package]]
name = "rustynes-cosim"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"rustynes-core",
"sha2",
@@ -129,7 +129,7 @@ dependencies = [
[[package]]
name = "rustynes-cpu"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags",
"thiserror",
@@ -137,7 +137,7 @@ dependencies = [
[[package]]
name = "rustynes-mappers"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags",
"rustynes-apu",
@@ -146,7 +146,7 @@ dependencies = [
[[package]]
name = "rustynes-ppu"
-version = "3.1.0"
+version = "3.2.0"
dependencies = [
"bitflags",
"libm",
diff --git a/crates/rustynes-cosim/Cargo.toml b/crates/rustynes-cosim/Cargo.toml
index ac6875959..0286ca730 100644
--- a/crates/rustynes-cosim/Cargo.toml
+++ b/crates/rustynes-cosim/Cargo.toml
@@ -8,7 +8,7 @@ description = "RustyNES as a co-simulation oracle for an external HDL device-und
# The duplication is PINNED, not merely noticed: `cosim_manifest_audit.rs` in
# `rustynes-test-harness` asserts these values still match the workspace's, so
# drift fails a test instead of accumulating quietly.
-version = "3.1.0"
+version = "3.2.0"
edition = "2024"
rust-version = "1.99"
license = "GPL-3.0-or-later"
diff --git a/crates/rustynes-cosim/src/bin/nes_golden_export.rs b/crates/rustynes-cosim/src/bin/nes_golden_export.rs
index 33f0dc87e..edffde368 100644
--- a/crates/rustynes-cosim/src/bin/nes_golden_export.rs
+++ b/crates/rustynes-cosim/src/bin/nes_golden_export.rs
@@ -6,8 +6,15 @@
//! nes_golden_export --rom --out [--seed N] [--frames N]
//! [--boot-trace START..END] [--irq-trace CAP]
//! [--checkpoint-interval N]
+//! [--four-score P1:P2:P3:P4] [--vaus POS:FIRE] [--genie CODE]...
//! ```
//!
+//! The last three (v3.2.0) attach STIMULUS devices for the whole run -- a Four
+//! Score with four pads, an Arkanoid paddle on `$4017`, Game Genie codes -- so
+//! the `RustyNES_MiSTer` input and cheat gates have a golden to compare against.
+//! Each is recorded in the manifest (`four_score`, `vaus`, `genie`) only when
+//! given; see `DeviceArgs`.
+//!
//! Writes, under ``:
//!
//! | file | format | consumed by |
@@ -93,6 +100,218 @@ struct Args {
inject_nmi_at: Option,
inject_irq_at: Option,
inject_hold: u64,
+ /// v3.2.0 (item 8) -- the input devices and cheat codes attached for the
+ /// whole run. See [`DeviceArgs`].
+ devices: DeviceArgs,
+ /// v3.2.0 — `--sprite-limit-off`: run with the v3.1.0 "Disable
+ /// 8-sprite-per-scanline limit" option ON (`Nes::set_sprite_limit_disabled`).
+ ///
+ /// The `MiSTer` core gains the same option (its FB-16), and the only way to
+ /// check a display option is to export the oracle's picture with it set.
+ /// It is render-only (`docs/ppu-2c02.md`), so every CPU-visible artifact of
+ /// such a run must be byte-identical to the same run without it; only the
+ /// framebuffer differs. Recorded in the manifest when set (as
+ /// `sprite_limit_off = true`), so a golden says which picture it is.
+ sprite_limit_off: bool,
+ /// v3.2.0 — `--fb-every-frame`: also write `.index_fb_frames.bin`,
+ /// the index framebuffer after EACH frame of the run, concatenated
+ /// (frames x 256 x 240 LE `u16`). The single `index_fb.bin` is the last
+ /// frame only, so a display option that is right on the last frame and
+ /// wrong on the others would pass a one-frame gate.
+ fb_every_frame: bool,
+}
+
+/// v3.2.0 (item 8): the STIMULUS the `RustyNES_MiSTer` input and cheat gates
+/// need on the oracle side -- a Four Score with four pads, an Arkanoid (Vaus)
+/// paddle on port 2, and Game Genie codes.
+///
+/// Every one of these is a console INPUT the emulator already models (`Nes::set_four_score`,
+/// `Nes::set_paddle`, `Nes::add_genie_code`); the exporter only had no way to
+/// ask for them, so a DUT implementing the same device had nothing to be
+/// compared against. They change no emulated behaviour of their own: with none
+/// given, every artifact and every manifest line is byte-identical to the
+/// exporter before this field existed (the manifest gains lines ONLY when one
+/// is set, so the 132 committed manifests stay exactly what they are).
+///
+/// Held CONSTANT for the run, deliberately. A frame-windowed schedule like
+/// `--press-start` would need the DUT's testbench to reproduce the frame
+/// boundaries in CPU cycles; a constant input needs nothing reproduced, and a
+/// stimulus ROM that reads the device every frame exercises every bit of it.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+struct DeviceArgs {
+ /// `--four-score P1:P2:P3:P4` -- the adapter on, and the four pads' button
+ /// bytes (bit order as `Buttons`: A, B, Select, Start, Up, Down, Left, Right
+ /// from bit 0).
+ four_score: Option<[u8; 4]>,
+ /// `--vaus POS:FIRE` -- an Arkanoid controller on port 2 (`$4017`), its
+ /// 8-bit knob position and whether the fire button is held.
+ vaus: Option<(u8, bool)>,
+ /// `--genie CODE`, repeatable -- 6- or 8-letter Game Genie codes, kept in
+ /// the order given (the manifest records them in that order).
+ genie: Vec,
+}
+
+/// Parse `--four-score P1:P2:P3:P4`: four two-digit hex bytes.
+///
+/// Exactly two digits each, so `1:2:3:4` is refused rather than read as
+/// `01:02:03:04` -- the manifest records the canonical form, and a recipe
+/// regenerated from it must reproduce the same argument the export saw.
+fn parse_four_score_spec(spec: &str) -> Option<[u8; 4]> {
+ let parts: Vec<&str> = spec.split(':').collect();
+ if parts.len() != 4 {
+ return None;
+ }
+ let mut out = [0u8; 4];
+ for (slot, p) in out.iter_mut().zip(&parts) {
+ if p.len() != 2 {
+ return None;
+ }
+ *slot = u8::from_str_radix(p, 16).ok()?;
+ }
+ Some(out)
+}
+
+/// Parse `--vaus POS:FIRE`: a two-digit hex knob position and `0` or `1`.
+fn parse_vaus_spec(spec: &str) -> Option<(u8, bool)> {
+ let (pos, fire) = spec.split_once(':')?;
+ if pos.len() != 2 {
+ return None;
+ }
+ let pos = u8::from_str_radix(pos, 16).ok()?;
+ let fire = match fire {
+ "0" => false,
+ "1" => true,
+ _ => return None,
+ };
+ Some((pos, fire))
+}
+
+/// Refuse a device request that would silently produce a golden describing
+/// something other than what was asked. `None` when the request is coherent.
+///
+/// Each rule names a combination that would RUN and export a plausible golden:
+///
+/// - **Four Score with `--press-start`.** Both write port 1's buttons; the
+/// press would overwrite the Four Score's pad 1 for its window and restore
+/// zero after it, so the manifest's `four_score` line would be false for part
+/// of the run.
+/// - **Four Score with `--vaus`.** The `NESdev` "Arkanoid controller" page: "No
+/// version is compatible with four player adapters." The oracle would let the
+/// paddle take over port 2 regardless, producing a stream no console can.
+/// - **Two Game Genie codes at one address.** The oracle keeps one code per
+/// address, so the second REPLACES the first; the manifest would list a code
+/// that was never active.
+/// - **An undecodable code** is refused here rather than at `add_genie_code`,
+/// so the message names the flag.
+fn device_error(args: &Args) -> Option {
+ let d = &args.devices;
+ if d.four_score.is_some() && args.press_start.is_some() {
+ return Some(
+ "--four-score and --press-start both drive port 1's buttons; the press \
+ would overwrite pad 1 for part of the run"
+ .to_owned(),
+ );
+ }
+ if d.four_score.is_some() && d.vaus.is_some() {
+ return Some(
+ "--four-score with --vaus: the Arkanoid controller is not compatible with \
+ four player adapters (NESdev \"Arkanoid controller\")"
+ .to_owned(),
+ );
+ }
+ let mut seen: Vec = Vec::with_capacity(d.genie.len());
+ for code in &d.genie {
+ let gc = match rustynes_core::GenieCode::new(code) {
+ Ok(gc) => gc,
+ Err(e) => return Some(format!("--genie {code:?}: {e}")),
+ };
+ if seen.contains(&gc.addr()) {
+ return Some(format!(
+ "--genie {code:?} patches ${:04X}, which an earlier --genie already \
+ patches; the oracle keeps one code per address, so the earlier one \
+ would silently stop applying",
+ gc.addr()
+ ));
+ }
+ seen.push(gc.addr());
+ }
+ None
+}
+
+/// Attach the requested devices and codes to a freshly constructed oracle.
+///
+/// Called before a single cycle runs, so the first strobe of the run already
+/// sees them. Infallible once [`device_error`] has passed; the `Result` is the
+/// genie decoder's, kept rather than unwrapped so a future path that skips the
+/// validation fails with a message instead of a panic.
+fn apply_devices(o: &mut Oracle, d: &DeviceArgs) -> Result<(), String> {
+ if let Some(pads) = d.four_score {
+ o.nes_mut().set_four_score(true);
+ for (port, bits) in pads.iter().enumerate() {
+ o.set_buttons(port, *bits);
+ }
+ }
+ if let Some((pos, fire)) = d.vaus {
+ // Port index 1 is `$4017`, where the NES (7-pin) Arkanoid controller
+ // reports -- the Famicom version's `$4016`/`$4017` split is a different
+ // device and is not what the MiSTer core models.
+ o.nes_mut().set_paddle(1, pos, fire);
+ }
+ for code in &d.genie {
+ o.nes_mut()
+ .add_genie_code(code)
+ .map_err(|e| format!("--genie {code:?}: {e}"))?;
+ }
+ Ok(())
+}
+
+/// Validate the device request, then attach it, or exit with the reason; and
+/// set `--sprite-limit-off`.
+///
+/// One call in `main`, which sits at the pedantic line limit, for the same
+/// reason [`arm_traces`] is one call. Both run before the first cycle. The
+/// sprite option is configuration, carried across a power cycle, and
+/// render-only, so setting it here changes the picture and nothing else (no
+/// work RAM byte, no bus cycle).
+fn attach_devices(o: &mut Oracle, args: &Args) {
+ if args.sprite_limit_off {
+ o.nes_mut().set_sprite_limit_disabled(true);
+ }
+ if let Some(why) = device_error(args) {
+ eprintln!("{why}");
+ usage();
+ }
+ if let Err(e) = apply_devices(o, &args.devices) {
+ eprintln!("{e}");
+ usage();
+ }
+}
+
+/// The manifest lines recording the devices, or the empty string when none
+/// was attached -- which is what keeps every existing manifest byte-identical.
+///
+/// Canonical forms (lower-case two-digit hex, upper-case codes) so the
+/// recipe generator in `RustyNES_MiSTer` can turn each line straight back into
+/// the flag that produced it.
+fn device_block(d: &DeviceArgs) -> String {
+ // Lines collected and joined rather than `write!`-ed into one `String`, so
+ // there is no `fmt::Result` to discard (the reason `sha256_hex` is written
+ // the way it is).
+ let mut lines: Vec = Vec::new();
+ if let Some(p) = d.four_score {
+ lines.push(format!(
+ "four_score = {:02x}:{:02x}:{:02x}:{:02x}\n",
+ p[0], p[1], p[2], p[3]
+ ));
+ }
+ if let Some((pos, fire)) = d.vaus {
+ lines.push(format!("vaus = {pos:02x}:{}\n", u8::from(fire)));
+ }
+ if !d.genie.is_empty() {
+ let codes: Vec = d.genie.iter().map(|c| c.to_ascii_uppercase()).collect();
+ lines.push(format!("genie = {}\n", codes.join(",")));
+ }
+ lines.concat()
}
/// The `--ppu-state-trace` capacity, refused outside a usable range.
@@ -128,7 +347,9 @@ fn usage() -> ! {
\x20 [--fetch-trace CAP] [--apu-trace CAP] [--checkpoint-interval N]\n\
\x20 [--inject-instructions N] [--inject-hold N]\n\
\x20 [--inject-nmi-at N] [--inject-irq-at N]\n\
- \x20 [--press-start A:B]\n\
+ \x20 [--press-start A:B] [--sprite-limit-off] [--fb-every-frame]\n\
+ \x20 [--four-score P1:P2:P3:P4] [--vaus POS:FIRE]\n\
+ \x20 [--genie CODE]...\n\
\x20 [--ppu-state-trace CAP --pst-frames A:B\n\
\x20 [--pst-scanlines A:B] [--pst-dots A:B]]"
);
@@ -185,6 +406,8 @@ fn parse_args() -> Args {
let (mut inject_instructions, mut inject_hold) = (0u64, 1u64);
let (mut inject_nmi_at, mut inject_irq_at) = (None, None);
let mut press_start: Option<(u64, u64)> = None;
+ let mut devices = DeviceArgs::default();
+ let (mut sprite_limit_off, mut fb_every_frame) = (false, false);
let mut i = 0;
// Every value-taking arm below advances `i` by TWO, not one: this loop has
@@ -305,6 +528,36 @@ fn parse_args() -> Args {
pst_dots = Some(parse_pair_u16(need(i)).unwrap_or_else(|| usage()));
i += 2;
}
+ // v3.2.0 (item 8). A malformed spec EXITS rather than being
+ // ignored: an unparsed device is a golden of a console with nothing
+ // plugged in, under a recipe claiming otherwise.
+ "--four-score" => {
+ devices.four_score = Some(parse_four_score_spec(need(i)).unwrap_or_else(|| {
+ eprintln!("--four-score wants P1:P2:P3:P4, four two-digit hex bytes");
+ usage()
+ }));
+ i += 2;
+ }
+ "--vaus" => {
+ devices.vaus = Some(parse_vaus_spec(need(i)).unwrap_or_else(|| {
+ eprintln!("--vaus wants POS:FIRE, a two-digit hex position and 0 or 1");
+ usage()
+ }));
+ i += 2;
+ }
+ "--genie" => {
+ devices.genie.push(need(i).clone());
+ i += 2;
+ }
+ // The two flags that take no value advance `i` by ONE.
+ "--sprite-limit-off" => {
+ sprite_limit_off = true;
+ i += 1;
+ }
+ "--fb-every-frame" => {
+ fb_every_frame = true;
+ i += 1;
+ }
"--checkpoint-interval" => {
checkpoint_interval = need(i).parse().unwrap_or_else(|_| usage());
if checkpoint_interval == 0 {
@@ -350,6 +603,9 @@ fn parse_args() -> Args {
inject_nmi_at,
inject_irq_at,
inject_hold,
+ devices,
+ sprite_limit_off,
+ fb_every_frame,
}
}
@@ -465,6 +721,21 @@ fn write_irq_artifacts(o: &mut Oracle, base: &Path, interval: u64) -> (usize, us
/// Returns the reason rather than exiting, so the rules are testable without a
/// process boundary.
fn injection_error(args: &Args) -> Option {
+ // `--fb-every-frame` captures each frame of a plain frame run. With
+ // `--inject-instructions` the injection branch runs instead and no frame
+ // completes, so the flag was silently ignored: no `index_fb_frames.bin`,
+ // exit 0, the "armed but empty" shape refused below for the APU trace
+ // (`CodeRabbit` on #604 and #605). `--press-start` was refused too, but
+ // only after the output directory, the devices and the traces were set
+ // up; both are refused here, before anything happens.
+ if args.fb_every_frame && (args.inject_instructions > 0 || args.press_start.is_some()) {
+ return Some(
+ "--fb-every-frame needs a plain frame run (no --press-start, no \
+ --inject-instructions): an injection run completes no frames, and no \
+ recipe combines it with --press-start"
+ .to_owned(),
+ );
+ }
// The APU trace is drained at FRAME boundaries, in `advance_frames`, and an
// injection run steps instructions and completes no frames -- so the two
// together would write a well-formed, EMPTY `.apu.bin` with a dropped count
@@ -869,8 +1140,60 @@ fn run_frames_with_optional_press(
calls
}
+/// Advance the run one frame at a time, writing the index framebuffer after
+/// each frame to `.index_fb_frames.bin` (v3.2.0, `--fb-every-frame`).
+///
+/// The same `run_frame()` calls `advance_frames(total)` would make, split at
+/// each frame boundary, so the run and every other artifact are unchanged.
+/// Refused with `--press-start` rather than combined with it: no recipe needs
+/// both, and an unexercised combination is a place a golden goes quietly wrong.
+fn run_frames_capturing_each(o: &mut Oracle, args: &Args, base: &Path) -> u64 {
+ use std::io::Write as _;
+ // `injection_error` has refused `--press-start` with this flag already.
+ //
+ // STREAMED, one frame at a time, through a buffered writer. The first
+ // version collected every frame in a Vec and wrote it at the end, so memory
+ // grew with `--frames` (about 120 KiB a frame: a 20,000-frame capture held
+ // 2.4 GB) and a run that was killed for it left no artifact at all
+ // (CodeRabbit on #604). Memory is now one frame's worth whatever the run
+ // length, and the file's bytes are the same: frames in order, each pixel a
+ // little-endian u16, nothing between them.
+ let path = suffixed(base, "index_fb_frames.bin");
+ let fail = |e: std::io::Error| -> ! { panic!("write {}: {e}", path.display()) };
+ let file = std::fs::File::create(&path).unwrap_or_else(|e| fail(e));
+ let mut out = std::io::BufWriter::new(file);
+ let mut frame = Vec::with_capacity(INDEX_FB_LEN * 2);
+ let mut calls = 0u64;
+ let mut written = 0u64;
+ for _ in 0..args.frames {
+ calls += o.advance_frames(1);
+ let fb = o.nes().index_framebuffer();
+ assert_eq!(
+ fb.len(),
+ INDEX_FB_LEN,
+ "unexpected index framebuffer length"
+ );
+ frame.clear();
+ for px in fb {
+ frame.extend_from_slice(&px.to_le_bytes());
+ }
+ out.write_all(&frame).unwrap_or_else(|e| fail(e));
+ written += frame.len() as u64;
+ }
+ // `into_inner` flushes and reports a failed final flush, which dropping a
+ // BufWriter would swallow; `sync_all` is not needed, `write` does not sync.
+ out.into_inner()
+ .map_err(std::io::IntoInnerError::into_error)
+ .unwrap_or_else(|e| fail(e));
+ println!(" wrote {} ({written} bytes)", path.display());
+ calls
+}
+
fn main() {
let args = parse_args();
+ // Refuse a contradictory argument set before ANY side effect: no output
+ // directory, no devices, no traces.
+ validate_injection(&args);
let rom =
std::fs::read(&args.rom).unwrap_or_else(|e| panic!("read {}: {e}", args.rom.display()));
let stem = args
@@ -880,6 +1203,7 @@ fn main() {
std::fs::create_dir_all(&args.out).expect("create out dir");
let mut o = Oracle::new(&rom, args.seed).unwrap_or_else(|e| panic!("parse rom: {e}"));
+ attach_devices(&mut o, &args);
// The power-on work RAM, captured BEFORE a single cycle runs.
//
@@ -906,7 +1230,7 @@ fn main() {
// number of instructions with a pin asserted for part of the run, which is
// the stimulus rung 2 compares. A frame advance would run past the window
// and bury the divergence under thousands of unrelated cycles.
- validate_injection(&args);
+ // (`validate_injection` already ran, first thing in `main`.)
let calls = if args.inject_instructions > 0 {
o.run_with_injection(
@@ -915,6 +1239,8 @@ fn main() {
args.inject_irq_at,
args.inject_hold,
)
+ } else if args.fb_every_frame {
+ run_frames_capturing_each(&mut o, &args, &args.out.join(&stem))
} else {
run_frames_with_optional_press(&mut o, u64::from(args.frames), args.press_start)
};
@@ -975,7 +1301,9 @@ fn main() {
(0, 0)
};
- let mode_block = run_mode_block(&args, calls, frames_actual);
+ // The device lines ride in the mode block: they describe the run, and an
+ // empty block leaves every pre-v3.2.0 manifest byte-identical.
+ let mode_block = run_mode_block(&args, calls, frames_actual) + &device_block(&args.devices);
let manifest = format!(
"rom = {}\n\
@@ -988,7 +1316,8 @@ fn main() {
ram_len = {}\n\
ckpt_interval= {}\n\
ckpt_count = {}\n\
- obs_count = {}\n",
+ obs_count = {}\n\
+ {}",
args.rom.display(),
sha256_hex(&rom),
args.seed,
@@ -1000,6 +1329,14 @@ fn main() {
args.checkpoint_interval,
checkpoint_count,
observable_count,
+ // Written only when set, as the device lines are, so every manifest
+ // from a run without the option stays byte-identical to the
+ // exporter before it; `RustyNES_MiSTer`'s Makefile looks for `true`.
+ if args.sprite_limit_off {
+ "sprite_limit_off = true\n"
+ } else {
+ ""
+ },
);
write(&suffixed(&base, "manifest.txt"), manifest.as_bytes());
println!("done; {cycles} CPU cycles simulated");
@@ -1068,6 +1405,9 @@ mod tests {
inject_nmi_at: None,
inject_irq_at: None,
inject_hold: 1,
+ devices: super::DeviceArgs::default(),
+ sprite_limit_off: false,
+ fb_every_frame: false,
};
let mut args = base();
@@ -1091,6 +1431,22 @@ mod tests {
sweep_only.inject_instructions = 8;
sweep_only.inject_nmi_at = Some(2);
assert!(injection_error(&sweep_only).is_none());
+
+ // `--fb-every-frame` with an injection run or `--press-start` is refused;
+ // with a plain frame run it is the normal sprite-limit recipe.
+ let mut fb_inject = base();
+ fb_inject.fb_every_frame = true;
+ fb_inject.inject_instructions = 8;
+ fb_inject.inject_nmi_at = Some(2);
+ let err = injection_error(&fb_inject).expect("refused");
+ assert!(err.contains("--fb-every-frame"), "{err}");
+ let mut fb_start = base();
+ fb_start.fb_every_frame = true;
+ fb_start.press_start = Some((10, 5));
+ assert!(injection_error(&fb_start).is_some());
+ let mut fb_frames = base();
+ fb_frames.fb_every_frame = true;
+ assert!(injection_error(&fb_frames).is_none());
}
use super::{Args, injection_error, sha256_hex, suffixed};
@@ -1186,3 +1542,200 @@ mod tests {
);
}
}
+
+/// v3.2.0 (item 8): the device and cheat stimulus flags.
+///
+/// The rules are tested through the REAL parsers and validator, and the
+/// attach path through a real `Oracle` running a real ROM -- a test that only
+/// checked the parsed `DeviceArgs` would pass while `apply_devices` attached
+/// nothing, which is the "armed but empty" shape this exporter refuses
+/// everywhere else.
+#[cfg(test)]
+mod device_tests {
+ use super::{
+ Args, DeviceArgs, apply_devices, device_block, device_error, parse_four_score_spec,
+ parse_vaus_spec,
+ };
+
+ fn args(devices: DeviceArgs) -> Args {
+ Args {
+ #[cfg(feature = "ppu-state-trace")]
+ ppu_state_trace: None,
+ rom: std::path::PathBuf::from("/dev/null"),
+ out: std::path::PathBuf::from("/tmp"),
+ seed: 0,
+ frames: 1,
+ press_start: None,
+ boot_trace: None,
+ irq_trace: None,
+ fetch_trace: None,
+ apu_trace: None,
+ checkpoint_interval: 4096,
+ inject_instructions: 0,
+ inject_nmi_at: None,
+ inject_irq_at: None,
+ inject_hold: 1,
+ devices,
+ sprite_limit_off: false,
+ fb_every_frame: false,
+ }
+ }
+
+ #[test]
+ fn four_score_spec_takes_exactly_four_two_digit_hex_bytes() {
+ assert_eq!(
+ parse_four_score_spec("01:82:44:a8"),
+ Some([0x01, 0x82, 0x44, 0xA8])
+ );
+ assert_eq!(
+ parse_four_score_spec("FF:00:0f:F0"),
+ Some([0xFF, 0x00, 0x0F, 0xF0])
+ );
+ for bad in [
+ "",
+ "01:82:44",
+ "01:82:44:a8:00",
+ "1:82:44:a8",
+ "001:82:44:a8",
+ "0g:82:44:a8",
+ ] {
+ assert_eq!(parse_four_score_spec(bad), None, "accepted {bad:?}");
+ }
+ }
+
+ #[test]
+ fn vaus_spec_takes_a_position_and_a_fire_bit() {
+ assert_eq!(parse_vaus_spec("a7:1"), Some((0xA7, true)));
+ assert_eq!(parse_vaus_spec("00:0"), Some((0x00, false)));
+ for bad in ["", "a7", "a7:2", "a7:", "7:1", "a7:1:0", "zz:1"] {
+ assert_eq!(parse_vaus_spec(bad), None, "accepted {bad:?}");
+ }
+ }
+
+ #[test]
+ fn incoherent_device_requests_are_refused() {
+ let fs = Some([1, 2, 3, 4]);
+ let mut a = args(DeviceArgs {
+ four_score: fs,
+ ..DeviceArgs::default()
+ });
+ assert!(device_error(&a).is_none(), "a Four Score alone is coherent");
+ a.press_start = Some((1, 2));
+ assert!(
+ device_error(&a).is_some_and(|e| e.contains("--press-start")),
+ "Four Score + press-start"
+ );
+
+ let both = args(DeviceArgs {
+ four_score: fs,
+ vaus: Some((0x80, false)),
+ genie: Vec::new(),
+ });
+ assert!(device_error(&both).is_some_and(|e| e.contains("four player")));
+
+ let bad_code = args(DeviceArgs {
+ genie: vec!["WXIOPO".to_owned()],
+ ..DeviceArgs::default()
+ });
+ assert!(device_error(&bad_code).is_some_and(|e| e.contains("WXIOPO")));
+
+ // SXIOPO and SXIOPOZZ decode to the SAME address ($91D9); the second
+ // would replace the first inside the oracle.
+ let dup = args(DeviceArgs {
+ genie: vec!["SXIOPO".to_owned(), "SXIOPOZZ".to_owned()],
+ ..DeviceArgs::default()
+ });
+ assert!(device_error(&dup).is_some_and(|e| e.contains("$91D9")));
+
+ let ok = args(DeviceArgs {
+ genie: vec!["SXIOPO".to_owned(), "YYKPOYZZ".to_owned()],
+ vaus: Some((0x10, true)),
+ ..DeviceArgs::default()
+ });
+ assert!(device_error(&ok).is_none());
+ }
+
+ #[test]
+ fn no_device_adds_no_manifest_line_and_each_device_adds_its_own() {
+ assert_eq!(device_block(&DeviceArgs::default()), "");
+ let d = DeviceArgs {
+ four_score: Some([0x01, 0x82, 0x44, 0xA8]),
+ vaus: None,
+ genie: vec!["sxiopo".to_owned(), "YYKPOYZZ".to_owned()],
+ };
+ assert_eq!(
+ device_block(&d),
+ "four_score = 01:82:44:a8\ngenie = SXIOPO,YYKPOYZZ\n"
+ );
+ let v = DeviceArgs {
+ vaus: Some((0xA7, true)),
+ ..DeviceArgs::default()
+ };
+ assert_eq!(device_block(&v), "vaus = a7:1\n");
+ }
+
+ /// A 16 KiB NROM that strobes the pads and stores the first `$4016` and
+ /// `$4017` reads at `$00`/`$01`, then spins.
+ fn strobe_rom() -> Vec {
+ let mut rom = vec![0u8; 16 + 16384 + 8192];
+ rom[..4].copy_from_slice(b"NES\x1a");
+ rom[4] = 1;
+ rom[5] = 1;
+ let code = [
+ 0xA9, 0x01, 0x8D, 0x16, 0x40, // LDA #1 ; STA $4016
+ 0xA9, 0x00, 0x8D, 0x16, 0x40, // LDA #0 ; STA $4016
+ 0xAD, 0x16, 0x40, 0x85, 0x00, // LDA $4016 ; STA $00
+ 0xAD, 0x17, 0x40, 0x85, 0x01, // LDA $4017 ; STA $01
+ 0x4C, 0x14, 0x80, // JMP $8014 (itself)
+ ];
+ rom[16..16 + code.len()].copy_from_slice(&code);
+ let v = 16 + 16384 - 6;
+ rom[v..v + 6].copy_from_slice(&[0x00, 0x80, 0x00, 0x80, 0x00, 0x80]);
+ rom
+ }
+
+ fn run(d: &DeviceArgs) -> (u8, u8, rustynes_cosim::Oracle) {
+ let mut o = rustynes_cosim::Oracle::new(&strobe_rom(), 0).expect("rom");
+ apply_devices(&mut o, d).expect("attach");
+ o.advance_frames(2);
+ let ram = o.nes().bus().ram_bytes();
+ (ram[0], ram[1], o)
+ }
+
+ #[test]
+ fn the_devices_actually_reach_the_emulated_ports() {
+ // Control: nothing attached. Open bus $40 from the operand's high byte,
+ // D0 = 0 from two unpressed pads.
+ let (p1, p2, _) = run(&DeviceArgs::default());
+ assert_eq!((p1, p2), (0x40, 0x40));
+
+ // Four Score: pad 1's A (bit 0) and pad 2's A are the first bits out.
+ let (p1, p2, o) = run(&DeviceArgs {
+ four_score: Some([0x01, 0x01, 0x00, 0x00]),
+ ..DeviceArgs::default()
+ });
+ assert!(o.nes().four_score());
+ assert_eq!((p1, p2), (0x41, 0x41));
+
+ // Vaus on $4017: knob $A7 has its MSb set, which goes out INVERTED on
+ // D4 (so 0), and fire held is D3 = 1; D0 is not driven. $4016 is
+ // still the standard pad.
+ let (p1, p2, _) = run(&DeviceArgs {
+ vaus: Some((0xA7, true)),
+ ..DeviceArgs::default()
+ });
+ assert_eq!((p1, p2), (0x40, 0x48));
+ let (_, p2, _) = run(&DeviceArgs {
+ vaus: Some((0x27, false)),
+ ..DeviceArgs::default()
+ });
+ assert_eq!(p2, 0x50, "MSb clear goes out as D4 = 1");
+
+ // Game Genie: every code is active, none silently dropped.
+ let (_, _, o) = run(&DeviceArgs {
+ genie: vec!["SXIOPO".to_owned(), "YYKPOYZZ".to_owned()],
+ ..DeviceArgs::default()
+ });
+ assert_eq!(o.nes().genie_codes().count(), 2);
+ }
+}
diff --git a/crates/rustynes-cosim/src/lib.rs b/crates/rustynes-cosim/src/lib.rs
index f48ddbdbc..b5b6d976d 100644
--- a/crates/rustynes-cosim/src/lib.rs
+++ b/crates/rustynes-cosim/src/lib.rs
@@ -697,6 +697,10 @@ macro_rules! oracle {
/// `handle` must be a live pointer from [`rn_open`].
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rn_run_frame(handle: *mut c_void) -> c_ulonglong {
+ // No `catch_unwind` here, deliberately (v3.2.0 review). A panic escaping an
+ // `extern "C"` function aborts the process, which is what a test oracle
+ // should do: a draft caught it in this one export and returned 0, which the
+ // co-simulation harness would have read as a cycle count.
let o = oracle!(handle, 0);
o.nes.run_frame();
o.nes.bus().cycle()
@@ -749,10 +753,25 @@ pub unsafe extern "C" fn rn_index_framebuffer(
if out.is_null() || cap < fb.len() {
return 0;
}
- // SAFETY: `out` is non-null and the caller guarantees `cap` writable u16s,
- // and `cap >= fb.len()` was just checked. The regions cannot overlap: `fb`
- // borrows emulator-owned memory, `out` is caller-owned.
- unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) };
+ // `out` is non-null and the caller guarantees `cap` writable u16s, and
+ // `cap >= fb.len()` was just checked. The regions cannot overlap: `fb`
+ // borrows emulator-owned memory, `out` is caller-owned. A C caller may pass
+ // a buffer that is not 2-byte aligned, which a `u16` copy may not touch
+ // (v3.2.0), so that case copies the same bytes as bytes.
+ if (out as usize) & (core::mem::align_of::() - 1) == 0 {
+ // SAFETY: as above, and `out` is aligned for `u16`.
+ unsafe { core::ptr::copy_nonoverlapping(fb.as_ptr(), out, fb.len()) };
+ } else {
+ // SAFETY: as above; a `u8` copy has no alignment requirement, and
+ // `size_of_val(fb)` bytes is exactly the `fb.len()` u16s checked to fit.
+ unsafe {
+ core::ptr::copy_nonoverlapping(
+ fb.as_ptr().cast::(),
+ out.cast::(),
+ core::mem::size_of_val(fb),
+ );
+ }
+ }
fb.len()
}
@@ -1309,6 +1328,22 @@ mod tests {
// A short buffer must be refused, not partially filled.
assert_eq!(rn_index_framebuffer(h, fb.as_mut_ptr(), 10), 0);
+ // An odd address: the byte-copy branch, which no other call
+ // reaches (`CodeRabbit` on #605). Same bytes as the aligned copy.
+ let n = 256 * 240;
+ // A sentinel fill, not zeros: this ROM's frame is mostly index 0,
+ // so a copy that stopped short would still match a zeroed buffer
+ // (the first draft's mutant, half a copy, was not caught).
+ let mut raw = vec![0xA5u8; n * 2 + 1];
+ let off = usize::from((raw.as_ptr() as usize) & 1 == 0);
+ // The misaligned pointer IS the subject: a C caller may hand one
+ // over, and the function must not do a `u16` copy through it.
+ #[allow(clippy::cast_ptr_alignment)]
+ let odd = raw.as_mut_ptr().add(off).cast::();
+ assert_eq!(rn_index_framebuffer(h, odd, n), n);
+ let want: Vec = fb.iter().flat_map(|p| p.to_ne_bytes()).collect();
+ assert_eq!(&raw[off..off + n * 2], &want[..]);
+
let mut ram = vec![0u8; 2048];
assert_eq!(rn_ram(h, ram.as_mut_ptr(), ram.len()), 2048);
assert_eq!(rn_ram(h, ram.as_mut_ptr(), 10), 0);
diff --git a/crates/rustynes-frontend/Cargo.toml b/crates/rustynes-frontend/Cargo.toml
index f15eed88b..33b3bf0f6 100644
--- a/crates/rustynes-frontend/Cargo.toml
+++ b/crates/rustynes-frontend/Cargo.toml
@@ -337,7 +337,7 @@ png = "0.18"
# so it reuses the already-present flate2 / miniz_oxide rather than pulling in
# the AES / zstd / bzip2 extras. The wasm load path receives raw bytes from the
# browser file picker, so it never needs zip.
-zip = { version = "8", default-features = false, features = ["deflate"] }
+zip = { version = "9", default-features = false, features = ["deflate"] }
# v1.8.5 — the HD-pack loader + compositor + HD audio moved to `rustynes-hdpack`
# (so the mobile bridge can reach it; the core is no_std). The frontend re-exports
diff --git a/crates/rustynes-frontend/src/app.rs b/crates/rustynes-frontend/src/app.rs
index c60c1cfc7..4d3f6cf00 100644
--- a/crates/rustynes-frontend/src/app.rs
+++ b/crates/rustynes-frontend/src/app.rs
@@ -211,13 +211,19 @@ fn extract_rom_from_zip(zip_bytes: &[u8]) -> Option<(String, Vec)> {
const MAX_ENTRY_BYTES: u64 = 64 * 1024 * 1024;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).ok()?;
let idx = (0..archive.len()).find(|&i| {
+ // zip 9: an entry whose name decodes as neither UTF-8 nor CP437 is
+ // skipped, the same as an entry that cannot be read at all.
archive.by_index(i).is_ok_and(|f| {
- std::path::Path::new(f.name()).extension().is_some_and(|e| {
- e.eq_ignore_ascii_case("nes")
- || e.eq_ignore_ascii_case("fds")
- || e.eq_ignore_ascii_case("nsf")
- || e.eq_ignore_ascii_case("unf")
- || e.eq_ignore_ascii_case("unif")
+ f.name().is_ok_and(|n| {
+ std::path::Path::new(n.as_ref())
+ .extension()
+ .is_some_and(|e| {
+ e.eq_ignore_ascii_case("nes")
+ || e.eq_ignore_ascii_case("fds")
+ || e.eq_ignore_ascii_case("nsf")
+ || e.eq_ignore_ascii_case("unf")
+ || e.eq_ignore_ascii_case("unif")
+ })
})
})
})?;
@@ -225,9 +231,15 @@ fn extract_rom_from_zip(zip_bytes: &[u8]) -> Option<(String, Vec)> {
if file.size() > MAX_ENTRY_BYTES {
return None;
}
- let name = std::path::Path::new(file.name())
- .file_name()
- .map_or_else(|| "rom".to_string(), |n| n.to_string_lossy().into_owned());
+ let name = file
+ .name()
+ .ok()
+ .and_then(|n| {
+ std::path::Path::new(n.as_ref())
+ .file_name()
+ .map(|f| f.to_string_lossy().into_owned())
+ })
+ .unwrap_or_else(|| "rom".to_string());
let cap = usize::try_from(file.size()).unwrap_or(0);
let mut out = Vec::with_capacity(cap);
file.take(MAX_ENTRY_BYTES).read_to_end(&mut out).ok()?;
@@ -2687,6 +2699,36 @@ impl App {
/// v1.8.9 — insert a specific FDS disk side (`Some(i)`) or eject (`None`) — the
/// Multi-Disk submenu's direct counterpart to [`Self::cycle_disk_side`] (a
/// multi-disk game prompts "insert side N"). An out-of-range side is ignored.
+ /// v3.2.0 — swipe `code` through the Datach barcode reader and report the
+ /// outcome on the status line. The core validates the digits again at its
+ /// boundary; a refusal there (malformed code, no reader) is shown, never
+ /// silently dropped. Callers have already asked `session_policy`.
+ fn scan_barcode(&mut self, code: &str) {
+ let result = {
+ let mut guard = self.emu.lock();
+ let emu = &mut *guard;
+ let Some(nes) = emu.nes.as_mut() else {
+ return;
+ };
+ let r = nes.scan_barcode(code);
+ // A scan is input a `.rnm` does not record, so, as for a Reset
+ // (NF-13), a history clip spanning it could not replay it: start a
+ // fresh timeline (`CodeRabbit` on #604).
+ if r.is_ok() {
+ emu.history.clear();
+ }
+ r
+ };
+ let message = match result {
+ Ok(()) => StatusMessage::info(crate::tf!(ShellBarcodeScanned, code)),
+ Err(rustynes_core::BarcodeScanError::Malformed(e)) => {
+ StatusMessage::error(crate::ui_shell::barcode_error_text(e))
+ }
+ Err(e) => StatusMessage::error(e.to_string()),
+ };
+ self.ui.set_status(message);
+ }
+
fn set_disk_side(&self, side: Option) {
#[cfg(not(target_arch = "wasm32"))]
self.flush_fds_save(true);
@@ -5465,6 +5507,14 @@ impl App {
self.set_disk_side(side);
}
}
+ MenuAction::ScanBarcode(code) => {
+ // v3.2.0 — a Datach scan is not in a movie's or netplay's
+ // input stream, so it is refused (with the reason) while
+ // either owns the timeline, like a disk swap.
+ if !self.refuse_timeline_action(TimelineAction::BarcodeScan) {
+ self.scan_barcode(&code);
+ }
+ }
MenuAction::Screenshot => {
#[cfg(not(target_arch = "wasm32"))]
self.take_screenshot();
@@ -7507,7 +7557,14 @@ impl App {
let raw_local = self.input.player1();
let turbo_mask = self.turbo_mask();
let turbo_period = self.config.input.turbo_period;
- let mut guard = self.emu.lock();
+ // v3.2.0: never block the UI thread here. The emulation thread idles
+ // during netplay, so the core is held only briefly by another thread
+ // (a present copy); if it is, this tick is skipped and the previous
+ // frame stays on screen. The rollback session sees that as one slow
+ // frame. A poisoned lock is recovered, not treated as held.
+ let Some(mut guard) = self.emu.try_lock() else {
+ return;
+ };
let emu = &mut *guard;
let Some(nes) = emu.nes.as_mut() else {
return;
@@ -10678,6 +10735,8 @@ impl ApplicationHandler for App {
disk_sides,
inserted_disk_side,
vs_system,
+ // v3.2.0 — a Datach cart (shows "Scan Barcode...").
+ barcode_reader,
mapper_label,
region_label,
movie_recording,
@@ -10701,6 +10760,7 @@ impl ApplicationHandler for App {
0usize,
None,
false,
+ false,
String::new(),
String::new(),
rec,
@@ -10720,6 +10780,7 @@ impl ApplicationHandler for App {
nes.disk_side_count(),
nes.inserted_disk_side(),
nes.is_vs_system(),
+ nes.has_barcode_reader(),
mapper_name,
region.to_string(),
rec,
@@ -10741,6 +10802,7 @@ impl ApplicationHandler for App {
disk_sides,
inserted_disk_side,
vs_system,
+ barcode_reader,
mapper_label: &mapper_label,
region_label: ®ion_label,
run_ahead,
@@ -13107,6 +13169,10 @@ mod tests {
"MenuAction::SetDiskSide(side) =>",
"TimelineAction::DiskSwap",
),
+ (
+ "MenuAction::ScanBarcode(code) =>",
+ "TimelineAction::BarcodeScan",
+ ),
("MenuAction::LoadState =>", "TimelineAction::LoadState"),
(
"MenuAction::LoadStateSlot(slot) =>",
diff --git a/crates/rustynes-frontend/src/atomic_write.rs b/crates/rustynes-frontend/src/atomic_write.rs
index c798af88c..e3a37d7ac 100644
--- a/crates/rustynes-frontend/src/atomic_write.rs
+++ b/crates/rustynes-frontend/src/atomic_write.rs
@@ -495,6 +495,14 @@ where
/// On failure **before the rename** the scratch file is removed and the existing
/// file is left untouched — a stale-but-valid file is the one worth keeping.
///
+/// # Permissions (Unix)
+///
+/// A file being REPLACED keeps its mode. A file being CREATED is created
+/// owner-only, `0o600` (v3.2.0; before, the umask default, typically `0o644`):
+/// these are save data, configuration and recordings, which no other user
+/// needs to read. A user who wants them shared can widen one, and the widened
+/// mode is then kept across every later save.
+///
/// # One failure happens AFTER the target is replaced
///
/// The parent-directory sync is the last step, and it necessarily runs *after*
@@ -589,9 +597,9 @@ where
// instead of truncating it (CWE-377).
opts.write(true).create_new(true);
#[cfg(unix)]
- if let Some(mode) = existing_mode {
+ {
use std::os::unix::fs::OpenOptionsExt as _;
- opts.mode(mode);
+ opts.mode(existing_mode.unwrap_or(0o600));
}
// Advance past an occupied scratch name, up to `SCRATCH_ATTEMPTS` times.
// A crashed run can orphan scratch files, the OS later reuses that pid,
@@ -644,10 +652,13 @@ where
// fails is replaced by one at the umask default -- which is WIDER. Reporting
// success there hands the caller a file with weaker permissions than the one
// it replaced, and says nothing. Found in review of the PR that introduced it.
+ //
+ // A NEW file gets the same exact set, at 0o600: creation alone gives
+ // `0o600 & !umask`, so under a umask that masks owner bits the file landed
+ // narrower than property 5 promises ("then set exactly"). Only existing
+ // files were set until `CodeRabbit` on #605.
#[cfg(unix)]
- if let Some(mode) = existing_mode
- && let Err(e) = apply_mode_using(&tmp, mode, set_permissions_mode)
- {
+ if let Err(e) = apply_mode_using(&tmp, existing_mode.unwrap_or(0o600), set_permissions_mode) {
let _ = fs::remove_file(&tmp);
return Err(e);
}
@@ -889,6 +900,18 @@ mod tests {
assert_eq!(mode, 0o600, "mode was widened to {mode:o}");
}
+ /// v3.2.0: a file that does not exist yet is created owner-only.
+ #[cfg(unix)]
+ #[test]
+ fn a_new_file_is_created_owner_only() {
+ use std::os::unix::fs::PermissionsExt as _;
+ let d = tempdir();
+ let p = d.join("new.sav");
+ write_atomic(&p, b"fresh").expect("write");
+ let mode = fs::metadata(&p).unwrap().permissions().mode() & 0o777;
+ assert_eq!(mode, 0o600, "a new file landed at {mode:o}");
+ }
+
/// A mode the UMASK would mask must still land exactly.
///
/// This exists because the test above does NOT actually pin property 5.
diff --git a/crates/rustynes-frontend/src/audio.rs b/crates/rustynes-frontend/src/audio.rs
index b6015cbcc..93c38573b 100644
--- a/crates/rustynes-frontend/src/audio.rs
+++ b/crates/rustynes-frontend/src/audio.rs
@@ -140,18 +140,15 @@ pub enum AudioError {
/// atomic bit patterns. Capacity is a power of two; head/tail are
/// monotonically increasing (wrapping) indices masked into the slot array.
struct Ring {
- slots: Box<[AtomicU64]>,
+ slots: Box<[AtomicU32]>,
mask: usize,
}
-// Each slot packs one f32's bits into the low half of an AtomicU64; using
-// 64-bit slots keeps the array uniform on 32-bit wasm targets too. (A plain
-// AtomicU32 would also work; the cost difference is noise at 16 K slots.)
impl Ring {
fn new(capacity_pow2: usize) -> Self {
debug_assert!(capacity_pow2.is_power_of_two());
let slots = (0..capacity_pow2)
- .map(|_| AtomicU64::new(0))
+ .map(|_| AtomicU32::new(0))
.collect::>()
.into_boxed_slice();
Self {
@@ -165,13 +162,26 @@ impl Ring {
}
}
+/// Cache-line alignment wrapper (64 bytes) to eliminate false sharing
+/// between producer and consumer threads on multi-core processors.
+#[repr(align(64))]
+struct CacheAligned(pub T);
+
+impl core::ops::Deref for CacheAligned {
+ type Target = T;
+ #[inline]
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
/// Shared queue state.
struct QueueInner {
ring: Ring,
/// Consumer (read) index — advanced by the audio callback only.
- head: AtomicUsize,
+ head: CacheAligned,
/// Producer (write) index — advanced by the app's produce path only.
- tail: AtomicUsize,
+ tail: CacheAligned,
/// Total samples dropped by `push` because the ring was full (overrun —
/// the producer ran ahead of the DAC clock) or skipped by a hard
/// resync.
@@ -275,8 +285,8 @@ impl SampleQueue {
Self {
inner: Arc::new(QueueInner {
ring: Ring::new(cap),
- head: AtomicUsize::new(0),
- tail: AtomicUsize::new(0),
+ head: CacheAligned(AtomicUsize::new(0)),
+ tail: CacheAligned(AtomicUsize::new(0)),
overrun_dropped: AtomicU64::new(0),
underruns: AtomicU64::new(0),
started: AtomicBool::new(false),
@@ -472,7 +482,7 @@ impl SampleQueue {
let n = samples.len().min(free);
for (i, &s) in samples[..n].iter().enumerate() {
self.inner.ring.slots[tail.wrapping_add(i) & self.inner.ring.mask]
- .store(u64::from(s.to_bits()), Ordering::Relaxed);
+ .store(s.to_bits(), Ordering::Relaxed);
}
self.inner
.tail
@@ -538,9 +548,8 @@ impl SampleQueue {
let gain = f32::from_bits(self.inner.gain.load(Ordering::Relaxed) as u32);
let n = out.len().min(avail);
for (i, o) in out[..n].iter_mut().enumerate() {
- #[allow(clippy::cast_possible_truncation)] // low 32 bits hold the f32.
let bits = self.inner.ring.slots[head.wrapping_add(i) & self.inner.ring.mask]
- .load(Ordering::Relaxed) as u32;
+ .load(Ordering::Relaxed);
*o = f32::from_bits(bits) * gain;
}
self.inner
diff --git a/crates/rustynes-frontend/src/audio_dsp.rs b/crates/rustynes-frontend/src/audio_dsp.rs
index 9689ebe45..9b2fc8e3c 100644
--- a/crates/rustynes-frontend/src/audio_dsp.rs
+++ b/crates/rustynes-frontend/src/audio_dsp.rs
@@ -141,16 +141,24 @@ struct Reverb {
allpasses: Vec,
}
+/// Schroeder's reference comb delays (ms), prime-ish to avoid flutter.
+const COMB_MS: [f32; 4] = [29.7, 37.1, 41.1, 43.7];
+
+/// The combs' outputs are averaged: `1 / COMB_MS.len()`. With four combs this
+/// is 0.25, and multiplying by a power-of-two reciprocal is bit-identical to
+/// the division it replaced (v3.2.0); deriving it from the table keeps the two
+/// from drifting apart if a comb is added.
+#[allow(clippy::cast_precision_loss)] // a handful of combs.
+const COMB_GAIN: f32 = 1.0 / COMB_MS.len() as f32;
+
impl Reverb {
/// Build a reverb voiced for `sample_rate`. `room` (0..=1) scales the comb
/// feedback (decay time); larger = longer tail.
fn new(sample_rate: u32, room: f32) -> Self {
let sr = sample_rate.max(1) as f32;
- // Schroeder's reference comb delays (ms), prime-ish to avoid flutter.
- let comb_ms = [29.7, 37.1, 41.1, 43.7];
let allpass_ms = [5.0, 1.7];
let feedback = Self::room_feedback(room);
- let combs = comb_ms
+ let combs = COMB_MS
.iter()
.map(|&ms| Comb::new(((ms / 1000.0) * sr) as usize, feedback))
.collect();
@@ -187,7 +195,7 @@ impl Reverb {
for c in &mut self.combs {
acc += c.process(x);
}
- acc /= self.combs.len().max(1) as f32;
+ acc *= COMB_GAIN;
for a in &mut self.allpasses {
acc = a.process(acc);
}
diff --git a/crates/rustynes-frontend/src/battery_policy.rs b/crates/rustynes-frontend/src/battery_policy.rs
index b5e94dc23..8162b893d 100644
--- a/crates/rustynes-frontend/src/battery_policy.rs
+++ b/crates/rustynes-frontend/src/battery_policy.rs
@@ -98,16 +98,20 @@ impl BatteryPolicy {
/// Whether `live` differs from the last successful write.
#[must_use]
- pub fn differs(&self, live: &[u8]) -> bool {
- live != self.last.as_slice()
+ pub fn differs(&self, live: &[u8], is_dirty: Option) -> bool {
+ if is_dirty == Some(false) {
+ false
+ } else {
+ live != self.last.as_slice()
+ }
}
/// The bytes to write now, if any: a copy of `live` when a comparison is
/// due ([`Self::tick`]) and `live` differs from the last write
/// ([`Self::differs`]). The copy lets the caller write without holding
/// the emulator.
- pub fn due(&mut self, live: &[u8], force: bool) -> Option> {
- (self.tick(force) && self.differs(live)).then(|| live.to_vec())
+ pub fn due(&mut self, live: &[u8], is_dirty: Option, force: bool) -> Option> {
+ (self.tick(force) && self.differs(live, is_dirty)).then(|| live.to_vec())
}
/// Record the outcome of writing `bytes`: on success they become the
@@ -168,30 +172,50 @@ mod tests {
let dirty = [1u8; 4];
let mut due = 0;
for _ in 0..(3 * CHECK_PERIOD_FRAMES) {
- if p.due(&dirty, false).is_some() {
+ if p.due(&dirty, None, false).is_some() {
due += 1;
}
}
assert_eq!(due, 3, "once per period, although always dirty");
- assert_eq!(p.due(&dirty, true), Some(dirty.to_vec()), "force: now");
+ assert_eq!(
+ p.due(&dirty, None, true),
+ Some(dirty.to_vec()),
+ "force: now"
+ );
}
#[test]
fn a_clean_save_is_never_due() {
let mut p = BatteryPolicy::new(vec![7; 4]);
- assert!(p.due(&[7; 4], true).is_none());
+ assert!(p.due(&[7; 4], None, true).is_none());
+ }
+
+ /// v3.2.0: a board that tracks its save data (`Some`) lets the policy
+ /// skip the byte comparison while it reports clean; untracked (`None`) and
+ /// dirty (`Some(true)`) boards are compared as before. The skip is only
+ /// sound because such a board marks every change dirty
+ /// (`flash_boards_mark_every_save_data_change_dirty` in the mappers).
+ #[test]
+ fn a_board_reporting_clean_skips_the_comparison() {
+ let mut p = BatteryPolicy::new(vec![0; 4]);
+ assert!(
+ p.due(&[1; 4], Some(false), true).is_none(),
+ "clean: skipped"
+ );
+ assert_eq!(p.due(&[1; 4], Some(true), true), Some(vec![1; 4]), "dirty");
+ assert_eq!(p.due(&[1; 4], None, true), Some(vec![1; 4]), "untracked");
}
#[test]
fn a_success_moves_the_baseline_and_a_failure_does_not() {
let mut p = BatteryPolicy::new(vec![0; 2]);
- let bytes = p.due(&[5, 6], true).unwrap();
+ let bytes = p.due(&[5, 6], None, true).unwrap();
assert!(p.written(bytes.clone(), false), "first failure: report");
assert_eq!(p.baseline(), &[0, 0], "baseline kept, so it retries");
assert!(!p.written(bytes.clone(), false), "second failure: quiet");
assert!(!p.written(bytes, true), "success reports nothing");
assert_eq!(p.baseline(), &[5, 6]);
- assert!(p.due(&[5, 6], true).is_none(), "and is now clean");
+ assert!(p.due(&[5, 6], None, true).is_none(), "and is now clean");
assert!(
p.written(vec![1, 1], false),
"a new run of failures reports"
diff --git a/crates/rustynes-frontend/src/battery_save.rs b/crates/rustynes-frontend/src/battery_save.rs
index 011ad9ada..bbcc73ef2 100644
--- a/crates/rustynes-frontend/src/battery_save.rs
+++ b/crates/rustynes-frontend/src/battery_save.rs
@@ -236,7 +236,9 @@ impl BatterySave {
/// thread for as long as the disk took (review on #551). Report the
/// outcome with [`Self::written`].
pub fn due_write(&mut self, nes: &Nes, force: bool) -> Option {
- let bytes = self.policy.due(nes.save_data(), force)?;
+ let bytes = self
+ .policy
+ .due(nes.save_data(), nes.is_save_data_dirty(), force)?;
Some(BatteryWrite {
path: self.path.clone(),
bytes,
diff --git a/crates/rustynes-frontend/src/emu.rs b/crates/rustynes-frontend/src/emu.rs
index 8f2041eff..c0595f990 100644
--- a/crates/rustynes-frontend/src/emu.rs
+++ b/crates/rustynes-frontend/src/emu.rs
@@ -240,6 +240,19 @@ impl EmuHandle {
guard
}
+ /// Lock the core if no other thread holds it, without waiting; `None`
+ /// only when it is held (v3.2.0, the netplay tick, which skips a frame
+ /// rather than block the UI thread). A poisoned lock is recovered exactly
+ /// as [`Self::lock`] recovers it -- reporting it as "held" would make that
+ /// caller skip every frame forever after one panic.
+ pub fn try_lock(&self) -> Option> {
+ match self.inner.try_lock() {
+ Ok(guard) => Some(guard),
+ Err(std::sync::TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
+ Err(std::sync::TryLockError::WouldBlock) => None,
+ }
+ }
+
/// Lock the core. Poisoning is ignored deliberately: a panic on one
/// thread must not wedge the other (the core's state is a plain value;
/// the next frame either works or panics identically).
@@ -2513,6 +2526,33 @@ pub(crate) fn drive_ra(
mod tests {
use super::*;
+ /// v3.2.0: `try_lock` is `None` only while another thread holds the core;
+ /// a poisoned lock is recovered as `lock` recovers it. Treating poison as
+ /// "held" made the netplay tick skip every frame forever after one panic.
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn try_lock_recovers_a_poisoned_core_and_refuses_only_a_held_one() {
+ let handle = EmuHandle::new(EmuCore::new());
+ assert!(handle.try_lock().is_some(), "free: taken");
+
+ let held = handle.lock();
+ let other = handle.clone();
+ let busy = std::thread::spawn(move || other.try_lock().is_none())
+ .join()
+ .unwrap();
+ assert!(busy, "held by another thread: None");
+ drop(held);
+
+ let poisoner = handle.clone();
+ let _ = std::thread::spawn(move || {
+ let _guard = poisoner.lock();
+ panic!("poison the core lock");
+ })
+ .join();
+ assert!(handle.inner.is_poisoned(), "fixture: the lock is poisoned");
+ assert!(handle.try_lock().is_some(), "poisoned: recovered, not None");
+ }
+
/// The shared cost model divides the measured cost over `running + 1`
/// frames: depth 0 is one frame, depth 2 is three.
#[test]
diff --git a/crates/rustynes-frontend/src/emu_thread.rs b/crates/rustynes-frontend/src/emu_thread.rs
index d69d18376..89337fb63 100644
--- a/crates/rustynes-frontend/src/emu_thread.rs
+++ b/crates/rustynes-frontend/src/emu_thread.rs
@@ -700,6 +700,12 @@ fn run_loop(
}
// Idle: no ROM yet, netplay owns the core on the winit thread, or the
// user paused emulation from the UX shell.
+ //
+ // `netplay_paused` MUST idle this thread: during netplay the winit
+ // thread advances the core through the rollback session, and two
+ // threads stepping one core in turn would desync it. A v3.2.0 import
+ // dropped this term (to stop the UI thread waiting on the lock); that
+ // goal is kept by the netplay tick's `try_lock` instead.
let idle = !control.has_rom.load(Ordering::Acquire)
|| control.netplay_paused.load(Ordering::Acquire)
|| control.user_paused.load(Ordering::Acquire);
diff --git a/crates/rustynes-frontend/src/i18n.rs b/crates/rustynes-frontend/src/i18n.rs
index 58a53919b..2ace156b8 100644
--- a/crates/rustynes-frontend/src/i18n.rs
+++ b/crates/rustynes-frontend/src/i18n.rs
@@ -598,6 +598,16 @@ catalog! {
ShellSpeedPct => "Speed: {0}%", Some("Velocidad: {0}%");
ShellRegion => "Region: {0}", Some("Región: {0}");
ShellSideN => "Side {0}", Some("Lado {0}");
+ ShellScanBarcode => "Scan Barcode...", Some("Escanear código de barras...");
+ ShellBarcodeTitle => "Datach Barcode Reader", Some("Lector de códigos de barras Datach");
+ ShellBarcodePrompt => "Type the 13- or 8-digit number printed under the card's barcode, then press Scan.", Some("Escribe el número de 13 u 8 dígitos impreso bajo el código de barras de la tarjeta y pulsa Escanear.");
+ ShellBarcodeScan => "Scan", Some("Escanear");
+ ShellBarcodeLocked => "Scanning is disabled while a movie records or plays, or during netplay: neither records a scan.", Some("El escaneo está desactivado mientras se graba o reproduce una película, o durante el juego en red: ninguno registra el escaneo.");
+ ShellBarcodeNeedDigits => "Enter 13 or 8 digits.", Some("Introduce 13 u 8 dígitos.");
+ ShellBarcodeNotDigit => "Character {0} ('{1}') is not a digit.", Some("El carácter {0} ('{1}') no es un dígito.");
+ ShellBarcodeWrongLength => "{0} digits: a JAN/EAN barcode has 13 or 8.", Some("{0} dígitos: un código JAN/EAN tiene 13 u 8.");
+ ShellBarcodeBadCheck => "The check digit is {0}; these digits need {1}.", Some("El dígito de control es {0}; estos dígitos necesitan {1}.");
+ ShellBarcodeScanned => "Scanned barcode {0}", Some("Código de barras escaneado: {0}");
ShellShowLagFramesHover => "Show a counter of frames where the game polled no controller (a TAS / debug diagnostic).", Some("Muestra un contador de fotogramas en los que el juego no leyó ningún control (un diagnóstico de TAS / depuración).");
ShellLagCount => "Lag: {0}", Some("Retraso: {0}");
ShellLagHover => "Lag frames since ROM load (no controller polled). Toggle in View -> Show Lag Frames.", Some("Fotogramas de retraso desde que se cargó la ROM (sin leer ningún control). Actívalo en Ver -> Mostrar fotogramas de retraso.");
diff --git a/crates/rustynes-frontend/src/icons.rs b/crates/rustynes-frontend/src/icons.rs
index c3da33fe8..0bf4a91d9 100644
--- a/crates/rustynes-frontend/src/icons.rs
+++ b/crates/rustynes-frontend/src/icons.rs
@@ -56,6 +56,8 @@ pub mod glyph {
pub const CLOCK_ROTATE_LEFT: char = '\u{f1da}';
/// `floppy-disk` — save state / save-slot actions.
pub const FLOPPY_DISK: char = '\u{f0c7}';
+ /// `barcode` — v3.2.0, the Datach barcode reader's "Scan Barcode..." item.
+ pub const BARCODE: char = '\u{f02a}';
/// `download` — load state / import.
pub const DOWNLOAD: char = '\u{f019}';
/// `camera`-like image; FA Solid `image` glyph for screenshots.
diff --git a/crates/rustynes-frontend/src/netplay_ui.rs b/crates/rustynes-frontend/src/netplay_ui.rs
index b7915797d..049db9890 100644
--- a/crates/rustynes-frontend/src/netplay_ui.rs
+++ b/crates/rustynes-frontend/src/netplay_ui.rs
@@ -152,6 +152,12 @@ pub struct NetplayUi {
/// captured at connect for the handshake + session
/// ([`SessionIdentity::of`]). The peer must announce an identical one.
identity: SessionIdentity,
+ /// When the session last confirmed a new frame (v3.2.0 peer liveness):
+ /// five seconds of stall past it ends the session with a reason.
+ last_peer_activity: std::time::Instant,
+ /// The newest confirmed frame seen, so only progress refreshes
+ /// `last_peer_activity`.
+ last_confirmed_tracker: u32,
/// Cached status for the HUD, refreshed each `tick`.
status: NetplayStatus,
/// Session config (input delay, rollback window, checksum interval). The
@@ -170,6 +176,8 @@ impl Default for NetplayUi {
state: NetplayState::Idle,
is_host: false,
identity: SessionIdentity::new([0u8; 32], [0u8; 32]),
+ last_peer_activity: std::time::Instant::now(),
+ last_confirmed_tracker: 0,
status: NetplayStatus::default(),
config: SessionConfig::default(),
spectator_delay_frames: 0,
@@ -422,6 +430,8 @@ impl NetplayUi {
let transport = conn.into_transport();
let session = RollbackSession::new(self.config, transport, self.identity);
self.state = NetplayState::InGame(Box::new(session));
+ self.last_peer_activity = std::time::Instant::now();
+ self.last_confirmed_tracker = 0;
self.status.phase = NetplayPhase::InGame;
NetplayTick {
active: true,
@@ -467,6 +477,15 @@ impl NetplayUi {
};
session.add_local_input(local_buttons);
+ // Peer liveness (v3.2.0): progress is a newly confirmed frame, which
+ // needs the peer's input. Five seconds without one while the session
+ // is stalled ends it with a reason instead of freezing silently.
+ if let Some(confirmed) = session.last_confirmed_frame()
+ && confirmed > self.last_confirmed_tracker
+ {
+ self.last_confirmed_tracker = confirmed;
+ self.last_peer_activity = std::time::Instant::now();
+ }
match session.advance(nes) {
Ok(AdvanceOutcome {
produced_frame,
@@ -474,6 +493,19 @@ impl NetplayUi {
resimulated_frames,
..
}) => {
+ if !produced_frame
+ && self.last_peer_activity.elapsed() > std::time::Duration::from_secs(5)
+ {
+ self.fail("peer connection lost (no data for several seconds)".to_string());
+ // `active: true`, like every other `fail()` path: the tick
+ // was the session's, and `false` would tell the caller to
+ // run a single-player frame on the failure tick (CodeRabbit
+ // on #604 and #605).
+ return NetplayTick {
+ active: true,
+ produced_frame: false,
+ };
+ }
self.status.phase = NetplayPhase::InGame;
self.status.is_host = self.is_host;
self.status.current_frame = session.current_frame();
@@ -755,6 +787,51 @@ mod tests {
assert_ne!(join.phase(), NetplayPhase::Error, "joiner did not error");
}
+ /// The tick that ends a session on a peer timeout still belongs to the
+ /// session: `active: true`, as on every other failure path, so the caller
+ /// does not run a stray single-player frame on it (`CodeRabbit` on #604).
+ #[test]
+ fn the_peer_timeout_tick_is_still_active() {
+ let rom = synth_nrom();
+ let hash = SessionIdentity::of(&Nes::from_rom(&rom).unwrap());
+ let probe = UdpSocket::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).unwrap();
+ let host_addr = probe.local_addr().unwrap();
+ drop(probe);
+ let mut host = NetplayUi::default();
+ host.start_host(host_addr.port(), 2, hash);
+ let mut join = NetplayUi::default();
+ join.start_join(host_addr, hash);
+ let mut nes_host = Nes::from_rom(&rom).unwrap();
+ let mut nes_join = Nes::from_rom(&rom).unwrap();
+ let mut rounds = 0;
+ while !(host.phase() == NetplayPhase::InGame && join.phase() == NetplayPhase::InGame)
+ && rounds < 500
+ {
+ host.tick(&mut nes_host, Buttons::empty());
+ join.tick(&mut nes_join, Buttons::empty());
+ rounds += 1;
+ std::thread::sleep(std::time::Duration::from_millis(2));
+ }
+ assert_eq!(host.phase(), NetplayPhase::InGame);
+
+ // The joiner goes silent; the host's last contact is six seconds ago.
+ let stale = std::time::Instant::now()
+ .checked_sub(std::time::Duration::from_secs(6))
+ .unwrap();
+ let mut failing_tick = None;
+ for _ in 0..300 {
+ host.last_peer_activity = stale;
+ let t = host.tick(&mut nes_host, Buttons::empty());
+ if host.phase() == NetplayPhase::Error {
+ failing_tick = Some(t);
+ break;
+ }
+ }
+ let t = failing_tick.expect("the host gives up on a silent peer");
+ assert!(t.active, "the failing tick is the session's");
+ assert!(!t.produced_frame);
+ }
+
/// v2.9.0 (`CodeRabbit` on #561): v2.9.0 made a power cycle keep battery
/// RAM, and the session start was a power cycle, so two peers holding
/// different saves began the "identical" cold boot with different save
diff --git a/crates/rustynes-frontend/src/present_buffer.rs b/crates/rustynes-frontend/src/present_buffer.rs
index a5524b1ae..35bbd5878 100644
--- a/crates/rustynes-frontend/src/present_buffer.rs
+++ b/crates/rustynes-frontend/src/present_buffer.rs
@@ -156,9 +156,15 @@ impl PresentBuffer {
self.generation.fetch_add(1, Ordering::Relaxed);
}
- /// Consumer: if a new frame was published since the last call, swap it
- /// into the front slot and copy it into `out` (the present-staging buffer
- /// the GPU uploads).
+ /// Consumer: if a new frame was published since the last call, hand it to
+ /// `out` (the present-staging buffer the GPU uploads).
+ ///
+ /// v3.2.0: by SWAPPING buffers, not copying 240 KiB. The fresh frame's
+ /// buffer moves into `out`, and `out`'s old buffer becomes the new front
+ /// slot. Nothing reads the front slot: `publish` writes only `back`, and
+ /// the next `take_into` reads `ready`, which a publish has refilled by
+ /// then. So the caller always receives the newest frame, and its old
+ /// buffer is recycled as storage (its contents are never presented).
///
/// Returns `Some(stamp)` when `out` was refreshed, where `stamp` is the
/// instant the emulation thread **published that frame** — carried per slot
@@ -186,9 +192,9 @@ impl PresentBuffer {
// Swap front <-> ready so the producer's next `publish` reuses our old
// front as its back, and we now own the freshest frame in `ready`.
slots.index = Self::pack(ready, front, back);
- out.clear();
- out.extend_from_slice(&slots.bufs[ready]);
- slots.stamps[ready]
+ let stamp = slots.stamps[ready];
+ std::mem::swap(&mut slots.bufs[ready], out);
+ stamp
}
/// True once at least one frame has been published (so the present path
diff --git a/crates/rustynes-frontend/src/session_policy.rs b/crates/rustynes-frontend/src/session_policy.rs
index 553fbf951..ae722068f 100644
--- a/crates/rustynes-frontend/src/session_policy.rs
+++ b/crates/rustynes-frontend/src/session_policy.rs
@@ -30,12 +30,15 @@
//! | --- | --- | --- | --- |
//! | Reset, Power Cycle | refused | refused | allowed |
//! | disk swap / insert / eject | refused | refused | allowed |
+//! | Datach barcode scan | refused | refused | allowed |
//! | state load | refused | refused | refused |
//!
//! Reset and Power Cycle stay legal in hardcore because rcheevos treats them
//! as legitimate (they are what a real console offers); a state load is the
//! one thing hardcore exists to forbid. A disk swap is an ordinary act of
-//! play on a real Famicom, so hardcore allows it too.
+//! play on a real Famicom, so hardcore allows it too, and so is swiping a
+//! card through a Datach (v3.2.0): a scan changes the machine through the
+//! cartridge's barcode reader, which neither a movie nor netplay carries.
//!
//! When more than one owner refuses, the hardcore reason is reported first
//! (it is the one the player cannot end on their own), then the movie, then
@@ -53,6 +56,8 @@ pub enum TimelineAction {
PowerCycle,
/// Any FDS disk change: cycle sides, insert a specific side, or eject.
DiskSwap,
+ /// v3.2.0 — a barcode swiped through the Datach reader (mapper 157).
+ BarcodeScan,
/// Restoring a save state (slot, manager, browser grid, or a script).
LoadState,
}
@@ -65,6 +70,7 @@ impl TimelineAction {
Self::Reset => "Reset",
Self::PowerCycle => "Power Cycle",
Self::DiskSwap => "Disk swap",
+ Self::BarcodeScan => "Barcode scan",
Self::LoadState => "Load state",
}
}
@@ -129,10 +135,11 @@ pub const fn refusal(action: TimelineAction, owners: SessionOwners) -> Option),
+ /// v3.2.0 — swipe this barcode (the digits as typed) through the Datach
+ /// reader (mapper 157). Validated again at the core boundary.
+ ScanBarcode(String),
/// v1.0.0 — capture a screenshot of the current framebuffer (native).
Screenshot,
/// v1.0.0 — copy the current framebuffer to the system clipboard (native;
@@ -293,6 +296,11 @@ pub struct UiShell {
/// Toggled by the freed backtick (`` ` ``) key, which formerly toggled the
/// (now-removed) debugger toolbar HUD.
pub ra_detail: bool,
+ /// v3.2.0 — whether the Datach barcode window is open.
+ pub show_barcode: bool,
+ /// v3.2.0 — the digits typed into the Datach barcode window. Kept between
+ /// openings so the same card can be swiped again.
+ pub barcode_input: String,
}
/// v1.5.0 "Lens" Workstream I9 — selects which device the Keyboard Shortcuts
@@ -366,6 +374,8 @@ impl UiShell {
active_slot: 0,
shortcuts_device: ShortcutsDevice::default(),
ra_detail: false,
+ show_barcode: false,
+ barcode_input: String::new(),
}
}
@@ -412,6 +422,9 @@ pub struct ShellFrame<'a> {
/// v1.0.0 — whether the loaded game is a Vs. System title (enables the
/// "Insert Coin" item).
pub vs_system: bool,
+ /// v3.2.0 — whether the cartridge has a barcode reader (the Datach,
+ /// mapper 157). Shows the Emulation menu's "Scan Barcode..." item.
+ pub barcode_reader: bool,
/// v1.0.0 — the human-readable mapper name (empty when unavailable).
pub mapper_label: &'a str,
/// v1.0.0 — the region label (`"NTSC"` / `"PAL"` / `"Dendy"`).
@@ -495,6 +508,7 @@ impl UiShell {
self.welcome_modal(&ctx, config);
about_window(&ctx, &mut self.show_about);
self.shortcuts_window(&ctx, config);
+ self.barcode_window(&ctx, frame, &mut out);
#[cfg(not(target_arch = "wasm32"))]
crate::about_fx::render(&ctx);
@@ -1043,6 +1057,24 @@ impl UiShell {
});
});
}
+ // v3.2.0 — the Datach barcode reader (mapper 157 only).
+ // Locked like the disk controls: a scan changes the
+ // machine outside the controller input a movie or a
+ // netplay session carries. The window says why.
+ if frame.barcode_reader {
+ ui.separator();
+ if ui
+ .add_enabled(
+ disk_interactive,
+ egui::Button::new(ic(glyph::BARCODE, crate::t!(ShellScanBarcode))),
+ )
+ .on_disabled_hover_text(crate::t!(ShellBarcodeLocked))
+ .clicked()
+ {
+ self.show_barcode = true;
+ ui.close();
+ }
+ }
});
// ----- View -----
@@ -2440,6 +2472,85 @@ fn about_window(ctx: &egui::Context, open: &mut bool) {
crate::about_fx::pump(ctx);
}
+/// The player-facing reason a typed barcode is not a valid JAN / EAN code,
+/// in the current locale (v3.2.0).
+#[must_use]
+pub fn barcode_error_text(err: rustynes_core::BarcodeError) -> String {
+ use rustynes_core::BarcodeError as E;
+ match err {
+ E::Empty => crate::t!(ShellBarcodeNeedDigits).to_string(),
+ E::NotADigit { index, found } => crate::tf!(ShellBarcodeNotDigit, index + 1, found),
+ E::WrongLength(n) => crate::tf!(ShellBarcodeWrongLength, n),
+ E::BadCheckDigit { expected, found } => crate::tf!(ShellBarcodeBadCheck, found, expected),
+ // `BarcodeError` is not `#[non_exhaustive]`; this arm is unreachable
+ // today and keeps the text honest if a variant is added.
+ #[allow(unreachable_patterns)]
+ other => other.to_string(),
+ }
+}
+
+impl UiShell {
+ /// v3.2.0 — the Datach barcode window: a digits field, a live validity
+ /// line (the same check the core applies at its boundary), and a Scan
+ /// button that dispatches [`MenuAction::ScanBarcode`]. Shown only while a
+ /// cartridge with a reader is loaded. Under a movie or netplay the field
+ /// stays editable but Scan is disabled, with the reason shown: a scan is
+ /// recorded by neither, so it would silently desynchronise them.
+ fn barcode_window(
+ &mut self,
+ ctx: &egui::Context,
+ frame: &ShellFrame<'_>,
+ out: &mut ShellOutput,
+ ) {
+ // A cartridge without a reader also closes the window: returning early
+ // left `show_barcode` set, so it reopened by itself on the next Datach
+ // game (`CodeRabbit` on #604).
+ if !frame.barcode_reader {
+ self.show_barcode = false;
+ return;
+ }
+ if !self.show_barcode {
+ return;
+ }
+ let locked = frame.movie_recording || frame.movie_playing || frame.netplay_active;
+ let mut open = self.show_barcode;
+ let input = &mut self.barcode_input;
+ egui::Window::new(crate::t!(ShellBarcodeTitle))
+ .open(&mut open)
+ .resizable(false)
+ .collapsible(false)
+ .show(ctx, |ui| {
+ ui.label(crate::t!(ShellBarcodePrompt));
+ let field = ui.add(
+ egui::TextEdit::singleline(input)
+ .hint_text("4901234567894")
+ .char_limit(20),
+ );
+ let parsed = rustynes_core::Barcode::parse(input);
+ // Say why Scan is disabled once something has been typed.
+ if let Err(e) = parsed
+ && !input.trim().is_empty()
+ {
+ ui.colored_label(ui.visuals().warn_fg_color, barcode_error_text(e));
+ }
+ if locked {
+ ui.colored_label(ui.visuals().warn_fg_color, crate::t!(ShellBarcodeLocked));
+ }
+ let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
+ let scan = ui
+ .add_enabled(
+ parsed.is_ok() && !locked,
+ egui::Button::new(crate::t!(ShellBarcodeScan)),
+ )
+ .clicked();
+ if (scan || enter) && parsed.is_ok() && !locked {
+ out.action = Some(MenuAction::ScanBarcode(input.trim().to_string()));
+ }
+ });
+ self.show_barcode = open;
+ }
+}
+
/// Render the keyboard-shortcuts window.
impl UiShell {
/// v1.5.0 "Lens" Workstream I9 — render the Keyboard Shortcuts window from
@@ -2703,6 +2814,21 @@ mod tests {
assert!(!shell.show_welcome);
}
+ /// v3.2.0 — each way a typed barcode can be wrong has its own visible
+ /// reason (English, the default locale), with the 1-based character
+ /// position and the check digit the other digits need.
+ #[test]
+ fn every_barcode_refusal_names_its_reason() {
+ let text = |s: &str| barcode_error_text(rustynes_core::Barcode::parse(s).unwrap_err());
+ assert_eq!(text(""), "Enter 13 or 8 digits.");
+ assert_eq!(text("12a"), "Character 3 ('a') is not a digit.");
+ assert_eq!(text("1234567"), "7 digits: a JAN/EAN barcode has 13 or 8.");
+ assert_eq!(
+ text("4006381333932"),
+ "The check digit is 2; these digits need 1."
+ );
+ }
+
#[test]
fn status_message_expires() {
let msg = StatusMessage::new("x", egui::Color32::WHITE, Duration::from_millis(0));
diff --git a/crates/rustynes-frontend/src/wasm_cheevos.rs b/crates/rustynes-frontend/src/wasm_cheevos.rs
index 1bff0464a..023d28dae 100644
--- a/crates/rustynes-frontend/src/wasm_cheevos.rs
+++ b/crates/rustynes-frontend/src/wasm_cheevos.rs
@@ -80,10 +80,17 @@ extern "C" {
#[wasm_bindgen(catch, js_name = ra_load_game)]
async fn ra_load_game(rom: &[u8]) -> Result;
- /// Drive one frame of achievement logic. `read_byte` is a JS callback that
- /// reads one NES CPU-bus byte. Returns a JSON array of this frame's events.
+ /// Drive one frame of achievement logic. `read_byte` reads one NES CPU-bus
+ /// byte. Returns a JSON array of this frame's events.
+ ///
+ /// `read_byte` is a wasm-bindgen STACK closure (`&mut dyn FnMut`): JS gets
+ /// a function that is valid only until this call returns, and wasm-bindgen
+ /// invalidates it then (a later call throws instead of reaching freed
+ /// memory). That is exactly how `ra_glue.js` uses it -- installed for the
+ /// one `rc_client_do_frame` and cleared in a `finally` -- so the borrow of
+ /// the emulator needs no `'static` and no `unsafe` (v3.2.0).
#[wasm_bindgen(js_name = ra_do_frame)]
- fn ra_do_frame(read_byte: &js_sys::Function) -> String;
+ fn ra_do_frame(read_byte: &mut dyn FnMut(u32) -> u32) -> String;
/// Tear down the client + module.
#[wasm_bindgen(js_name = ra_shutdown)]
@@ -181,38 +188,20 @@ impl BrowserRaSession {
/// events raised this frame (for HUD toasts). No-op (empty) when the module
/// is not initialized.
///
- /// The `read` closure is wrapped in a JS function for the side module's
- /// read-memory trampoline; it is only invoked synchronously inside the
- /// `ra_do_frame` call, so the borrow never escapes.
+ /// `read` reaches the side module's read-memory trampoline as a
+ /// wasm-bindgen stack closure, valid only for the synchronous
+ /// `ra_do_frame` call, so the borrow cannot escape -- checked by the
+ /// compiler and enforced by wasm-bindgen, not argued in a comment.
pub fn do_frame(&self, read: &mut dyn FnMut(u16) -> u8) -> Vec {
if !self.initialized {
return Vec::new();
}
- // `Closure::new` requires a `'static` body, but our `read` borrows the
- // emulator and only needs to be valid for this synchronous call.
- // `ra_do_frame` invokes the JS callback ONLY synchronously while we are
- // inside it (the browser is single-threaded and the side module never
- // retains the function pointer past the call), then we drop the closure
- // before returning. So we erase the borrow's lifetime to `'static`,
- // matching the native bridge's `ReadGuard` idiom.
- //
- // The attribute goes ABOVE the SAFETY note, not between it and the `let`.
- // `clippy::undocumented_unsafe_blocks` looks at the line immediately
- // preceding the statement, so an attribute sitting there hides the note
- // and the lint fires -- measured both ways rather than assumed. (Review
- // on #423 suggested the note alone would do; it does, but only in this
- // order.)
- #[allow(unsafe_code)] // localized lifetime erasure; see SAFETY below
- // SAFETY: the erased pointer is only dereferenced by `ra_do_frame`
- // synchronously below, and `closure` is dropped at the end of this
- // function, so the call can never outlive the real borrow.
- let read_static: &mut (dyn FnMut(u16) -> u8 + 'static) =
- unsafe { core::mem::transmute(read) };
- let closure = Closure:: u32>::new(move |addr: u32| {
- u32::from(read_static((addr & 0xffff) as u16))
- });
- let json = ra_do_frame(closure.as_ref().unchecked_ref());
- drop(closure);
+ // A stack closure borrowing `read` for this synchronous call only (see
+ // `ra_do_frame`). Until v3.2.0 this was a `'static` `Closure` over a
+ // `transmute`d `read`, sound by argument (the callback is used only
+ // inside the call) but not checkable by the compiler; now it is.
+ let mut read_u32 = |addr: u32| u32::from(read((addr & 0xffff) as u16));
+ let json = ra_do_frame(&mut read_u32);
parse_unlock_titles(&json)
}
diff --git a/crates/rustynes-frontend/src/web_battery.rs b/crates/rustynes-frontend/src/web_battery.rs
index 138586e0a..4c9f7c219 100644
--- a/crates/rustynes-frontend/src/web_battery.rs
+++ b/crates/rustynes-frontend/src/web_battery.rs
@@ -283,7 +283,7 @@ impl WebBattery {
let bytes = match in_flight {
Some(pending) if force => (live != pending.as_slice()).then(|| live.to_vec())?,
Some(_) => return None,
- None => policy.due(live, force)?,
+ None => policy.due(live, nes.is_save_data_dirty(), force)?,
};
*in_flight = Some(bytes.clone());
Some(WebBatteryWrite { sha: *sha, bytes })
diff --git a/crates/rustynes-gamedb/src/lib.rs b/crates/rustynes-gamedb/src/lib.rs
index 5aa73e16e..29360afe2 100644
--- a/crates/rustynes-gamedb/src/lib.rs
+++ b/crates/rustynes-gamedb/src/lib.rs
@@ -539,7 +539,7 @@ pub fn apply_header_overrides(bytes: &mut [u8], entry: &GameDbEntry) -> bool {
}
if let Some(submapper) = entry.submapper
- && submapper != 0
+ && (submapper != 0 || is_nes2)
{
// The submapper field only exists in NES 2.0 headers. A correction that
// sets a non-zero submapper on an iNES-1.0 ROM (e.g. Seicross, whose
@@ -547,6 +547,13 @@ pub fn apply_header_overrides(bytes: &mut [u8], entry: &GameDbEntry) -> bool {
// CHR-disable copy-protection variant) must first PROMOTE the header to
// NES 2.0 by setting byte-7 bits 2-3 to `10`, otherwise the core's iNES
// path forces submapper 0 and the override is silently dropped.
+ //
+ // A correction TO submapper 0 is applied only to an NES 2.0 header (an
+ // iNES-1.0 header already reads as 0, so promoting it would change
+ // nothing but the header). It can only come from the user overlay:
+ // `load_time_entry` clears the vendored table's submapper for NES 2.0
+ // headers, and the overlay's `0` is a deliberate correction
+ // (`parse_overlay_row`). Before v3.2.0 such a correction was dropped.
if !is_nes2 {
bytes[7] = (bytes[7] & 0xF3) | 0x08;
// Promotion reinterprets header fields the iNES-1.0 spec leaves
@@ -710,15 +717,20 @@ fn ines1_as_nes2(bytes: &[u8], region: Region) -> Option<[u8; 16]> {
};
let chr_ram = if h.chr_size == 0 { SHIFT_8K } else { 0 };
- for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] {
- header[10] = byte10;
- header[11] = byte11;
- let mut image = bytes.to_vec();
- image[..16].copy_from_slice(&header);
- if let Ok(promoted) = parse(&image)
- && same_board(&original, &promoted)
- {
- return Some(header);
+ // Lowest submapper first, so a board every submapper can express keeps 0.
+ // See `same_board` for why the submapper itself is not compared.
+ let mut image = bytes.to_vec();
+ for sub in 0..16 {
+ header[8] = sub << 4;
+ for (byte10, byte11) in [(prg_ram, chr_ram), (0, 0)] {
+ header[10] = byte10;
+ header[11] = byte11;
+ image[..16].copy_from_slice(&header);
+ if let Ok(promoted) = parse(&image)
+ && same_board(&original, &promoted)
+ {
+ return Some(header);
+ }
}
}
None
@@ -744,8 +756,15 @@ fn same_board(
),
) -> bool {
let ((ca, ma), (cb, mb)) = (a, b);
+ // No `submapper` comparison, deliberately (v3.2.0). For an iNES 1.0
+ // original the parsed submapper is always 0, so against the old sub-0
+ // candidate the check could never fail; and some boards read an iNES 1.0
+ // header as a NON-zero NES 2.0 submapper -- an iNES 1.0 mapper 6 is FFE
+ // latch mode 1, which NES 2.0 spells submapper 1 -- so the check refused
+ // the one candidate that IS the same board. `ines1_as_nes2` therefore
+ // tries every submapper, lowest first, and the board is identified by
+ // what it does: the state, the debug description and the sizes below.
ca.mapper_id == cb.mapper_id
- && ca.submapper == cb.submapper
&& ca.mirroring == cb.mirroring
&& ca.console_type == cb.console_type
&& ca.vs_ppu_type == cb.vs_ppu_type
@@ -760,7 +779,7 @@ fn same_board(
&& ma.sram().len() == mb.sram().len()
&& ma.save_data().len() == mb.save_data().len()
&& ma.save_state() == mb.save_state()
- && format!("{:?}", ma.debug_info()) == format!("{:?}", mb.debug_info())
+ && ma.debug_info() == mb.debug_info()
}
/// Stage one of the load-time correction path every platform shares (v2.9.8):
@@ -967,17 +986,31 @@ pub fn rom_crc32_full(bytes: &[u8]) -> Option {
/// IEEE CRC-32 (reflected, polynomial `0xEDB8_8320`) — the zip/PNG CRC, matching
/// TetaNES' `compute_crc32`. Table-less; runs once per ROM load.
-fn crc32(data: &[u8]) -> u32 {
- let mut crc = 0xFFFF_FFFFu32;
- for &byte in data {
- crc ^= u32::from(byte);
- for _ in 0..8 {
+const CRC32_TABLE: [u32; 256] = {
+ let mut table = [0u32; 256];
+ let mut i = 0;
+ while i < 256 {
+ #[allow(clippy::cast_possible_truncation)]
+ let mut crc = i as u32;
+ let mut j = 0;
+ while j < 8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xEDB8_8320
} else {
crc >> 1
};
+ j += 1;
}
+ table[i] = crc;
+ i += 1;
+ }
+ table
+};
+
+fn crc32(data: &[u8]) -> u32 {
+ let mut crc = 0xFFFF_FFFFu32;
+ for &byte in data {
+ crc = (crc >> 8) ^ CRC32_TABLE[((crc ^ u32::from(byte)) & 0xFF) as usize];
}
!crc
}
@@ -1335,6 +1368,34 @@ mod tests {
assert!(!is_multi_region_title("Gyromite (World).nes"));
}
+ /// A correction TO submapper 0 lands on an NES 2.0 header (it can only come
+ /// from the user overlay, whose `0` is deliberate). Before v3.2.0 any
+ /// sub-0 correction was skipped, so an overlay could not undo a wrong
+ /// non-zero submapper in an NES 2.0 dump.
+ #[test]
+ fn sub_zero_correction_applies_to_an_nes2_header() {
+ let mut rom = vec![0u8; 16 + 32 * 1024 + 8 * 1024];
+ rom[0..4].copy_from_slice(b"NES\x1A");
+ rom[4] = 2;
+ rom[5] = 1;
+ rom[6] = 0xB1; // mapper 185, vertical
+ rom[7] = 0xB8; // NES 2.0
+ rom[8] = 0x40; // submapper 4
+ let entry = GameDbEntry {
+ crc: 0,
+ region: None,
+ mapper: None,
+ submapper: Some(0),
+ mirroring: None,
+ title: "overlay sub-0 correction".into(),
+ };
+ assert!(apply_header_overrides(&mut rom, &entry));
+ assert_eq!(rom[8] >> 4, 0, "submapper corrected to 0");
+ assert_eq!(rom[7] & 0x0C, 0x08, "still NES 2.0");
+ // Idempotent: applying it again changes nothing.
+ assert!(!apply_header_overrides(&mut rom, &entry));
+ }
+
#[test]
fn submapper_override_promotes_ines1_to_nes2() {
// A non-zero submapper correction on an iNES-1.0 ROM must promote the
diff --git a/crates/rustynes-gfx-shaders/src/crt_guest.wgsl b/crates/rustynes-gfx-shaders/src/crt_guest.wgsl
index a7e9ae7f3..c1c514608 100644
--- a/crates/rustynes-gfx-shaders/src/crt_guest.wgsl
+++ b/crates/rustynes-gfx-shaders/src/crt_guest.wgsl
@@ -101,11 +101,11 @@ fn glow(suv: vec2) -> vec3 {
let tx = 1.0 / 256.0;
let ty = 1.0 / 240.0;
var acc = vec3(0.0, 0.0, 0.0);
- acc = acc + textureSample(nes_tex, nes_smp, suv).rgb * 0.4;
- acc = acc + textureSample(nes_tex, nes_smp, suv + vec2( 2.0 * tx, 0.0)).rgb * 0.15;
- acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(-2.0 * tx, 0.0)).rgb * 0.15;
- acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(0.0, 2.0 * ty)).rgb * 0.15;
- acc = acc + textureSample(nes_tex, nes_smp, suv + vec2(0.0, -2.0 * ty)).rgb * 0.15;
+ acc = acc + textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb * 0.4;
+ acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2( 2.0 * tx, 0.0), 0.0).rgb * 0.15;
+ acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(-2.0 * tx, 0.0), 0.0).rgb * 0.15;
+ acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(0.0, 2.0 * ty), 0.0).rgb * 0.15;
+ acc = acc + textureSampleLevel(nes_tex, nes_smp, suv + vec2(0.0, -2.0 * ty), 0.0).rgb * 0.15;
return acc;
}
@@ -129,7 +129,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
}
let suv = vec2(uv.x * u.crop.z + u.crop.w, uv.y * u.crop.x + u.crop.y);
- var rgb = textureSample(nes_tex, nes_smp, suv).rgb;
+ var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb;
rgb = pow(rgb, vec3(2.2)); // to linear
// Scanline beam in source-row space.
diff --git a/crates/rustynes-gfx-shaders/src/crt_royale.wgsl b/crates/rustynes-gfx-shaders/src/crt_royale.wgsl
index 345cb4803..f47b25007 100644
--- a/crates/rustynes-gfx-shaders/src/crt_royale.wgsl
+++ b/crates/rustynes-gfx-shaders/src/crt_royale.wgsl
@@ -134,7 +134,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
for (var i = -1; i <= 1; i = i + 1) {
let ry = centre + f32(i);
let sample_v = (ry + 0.5) / rows;
- var c = textureSample(nes_tex, nes_smp, vec2(suv.x, sample_v)).rgb;
+ var c = textureSampleLevel(nes_tex, nes_smp, vec2(suv.x, sample_v), 0.0).rgb;
c = pow(c, vec3(gamma_in)); // to linear
// Beam brightness scales its own width: brighter rows spread more.
let lum = dot(c, vec3(0.299, 0.587, 0.114));
diff --git a/crates/rustynes-gfx-shaders/src/lib.rs b/crates/rustynes-gfx-shaders/src/lib.rs
index 0f75b6979..b15c6c858 100644
--- a/crates/rustynes-gfx-shaders/src/lib.rs
+++ b/crates/rustynes-gfx-shaders/src/lib.rs
@@ -109,7 +109,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
}
// Overscan crop: remap the visible V (crop.xy) and U (crop.zw) ranges.
let suv = vec2(in.uv.x * u.crop.z + u.crop.w, in.uv.y * u.crop.x + u.crop.y);
- var rgb = textureSample(nes_tex, nes_smp, suv).rgb;
+ var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb;
// v2.2.8 'Aperture II': do the scanline + mask *darkening* in LINEAR light so a
// scanline valley at 50% is 50% of the LINEAR luminance (perceptually correct),
@@ -277,7 +277,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
let fs = f32(s);
let samp_col = col + fs / f32(SUB);
let uvx = samp_col * texel;
- let src = textureSample(nes_tex, nes_smp, vec2(uvx, suv.y)).rgb;
+ let src = textureSampleLevel(nes_tex, nes_smp, vec2(uvx, suv.y), 0.0).rgb;
let yiq = rgb2yiq(src);
// Subcarrier phase at this composite sample.
let ph = TAU * (samp_col / f32(SUB) + phase0) ;
diff --git a/crates/rustynes-gfx-shaders/src/megatron.wgsl b/crates/rustynes-gfx-shaders/src/megatron.wgsl
index 6fd917cb1..4bd781588 100644
--- a/crates/rustynes-gfx-shaders/src/megatron.wgsl
+++ b/crates/rustynes-gfx-shaders/src/megatron.wgsl
@@ -115,7 +115,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
}
let suv = vec2(uv.x * u.crop.z + u.crop.w, uv.y * u.crop.x + u.crop.y);
- var rgb = textureSample(nes_tex, nes_smp, suv).rgb;
+ var rgb = textureSampleLevel(nes_tex, nes_smp, suv, 0.0).rgb;
rgb = pow(rgb, vec3(2.4)); // to linear (Megatron uses ~2.4 EOTF)
// Gaussian beam in source-row space, scaled by luminance (thicker beam for
diff --git a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl
index eae25cd7b..22187b3dc 100644
--- a/crates/rustynes-gfx-shaders/src/signal_decode.wgsl
+++ b/crates/rustynes-gfx-shaders/src/signal_decode.wgsl
@@ -94,11 +94,7 @@ fn pmod(x: i32, n: i32) -> i32 {
// Reconstruct one normalized composite sample for source column `col` at
// sub-sample `sub` (0..8), given the packed index texel and the line phase.
-fn composite_at(col: i32, sub: i32, row: i32, line_phase: i32) -> f32 {
- let dim = textureDimensions(idx_tex);
- let cx = clamp(col, 0, i32(dim.x) - 1);
- let cy = clamp(row, 0, i32(dim.y) - 1);
- let packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r);
+fn composite_at_packed(packed: i32, col: i32, sub: i32, line_phase: i32) -> f32 {
let index = packed & 0x3F;
let emphasis = (packed >> 6) & 0x7;
@@ -151,7 +147,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
let row = i32(floor(suv.y * rows));
// Per-line phase offset gives the 3-phase dot crawl (params.x is the frame
// videoPhase; the row parity contributes the per-line shift). Wrapped
- // non-negative for the same edge-consistency reason as `composite_at`.
+ // non-negative for the same edge-consistency reason as `composite_at_packed`'s caller.
let line_phase = pmod(i32(round(u.params.x)) + row * 8, 12);
// Centre position in absolute sub-samples: 8 per pixel.
@@ -184,11 +180,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 {
var i_acc = 0.0;
var q_acc = 0.0;
var w_sum = 0.0;
+ var last_col: i32 = -999999;
+ var packed: i32 = 0;
for (var s = -half; s <= half; s = s + 1) {
let abs_sub = centre + f32(s);
let col = i32(floor(abs_sub / 8.0));
let sub = i32(floor(abs_sub)) % 8;
- let comp = composite_at(col, sub, row, line_phase);
+ if (col != last_col) {
+ let cx = clamp(col, 0, i32(dim.x) - 1);
+ let cy = clamp(row, 0, i32(dim.y) - 1);
+ packed = i32(textureLoad(idx_tex, vec2(cx, cy), 0).r);
+ last_col = col;
+ }
+ let comp = composite_at_packed(packed, col, sub, line_phase);
// Triangular low-pass window.
let w = 1.0 - abs(f32(s)) / (f32(half) + 1.0);
y_acc = y_acc + comp * w;
diff --git a/crates/rustynes-hdpack/Cargo.toml b/crates/rustynes-hdpack/Cargo.toml
index 65f439a9a..a0a991abf 100644
--- a/crates/rustynes-hdpack/Cargo.toml
+++ b/crates/rustynes-hdpack/Cargo.toml
@@ -21,7 +21,7 @@ rustynes-ppu = { workspace = true, features = ["hd-pack"] }
# Decode replacement tiles (png), load `.zip` packs (deflate only), and decode
# HD-audio OGG tracks (lewton is pure-Rust, so it cross-compiles to Android).
png = "0.18"
-zip = { version = "8", default-features = false, features = ["deflate"] }
+zip = { version = "9", default-features = false, features = ["deflate"] }
lewton = "0.10"
[dev-dependencies]
diff --git a/crates/rustynes-hdpack/src/hdpack.rs b/crates/rustynes-hdpack/src/hdpack.rs
index a1d87efa1..66bc396db 100644
--- a/crates/rustynes-hdpack/src/hdpack.rs
+++ b/crates/rustynes-hdpack/src/hdpack.rs
@@ -567,7 +567,9 @@ impl HdPack {
// Find the `hires.txt` entry (allow it to live in a subfolder).
let hires_name = (0..archive.len()).find_map(|i| {
let e = archive.by_index(i).ok()?;
- let name = e.name().to_string();
+ // zip 9: `name()` fails only for a name that is neither UTF-8 nor
+ // CP437; such an entry cannot be `hires.txt`, so skip it.
+ let name = e.name().ok()?.into_owned();
Path::new(&name)
.file_name()
.is_some_and(|f| f.eq_ignore_ascii_case("hires.txt"))
@@ -2812,7 +2814,7 @@ mod tests {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
for i in 0..archive.len() {
let mut entry = archive.by_index(i).unwrap();
- let mut out = std::fs::File::create(dir.join(entry.name())).unwrap();
+ let mut out = std::fs::File::create(dir.join(entry.name().unwrap().as_ref())).unwrap();
std::io::copy(&mut entry, &mut out).unwrap();
}
let one = 64 * 64 * 4;
diff --git a/crates/rustynes-ios/src/ffi.rs b/crates/rustynes-ios/src/ffi.rs
index 41e8996b3..22d31fcc7 100644
--- a/crates/rustynes-ios/src/ffi.rs
+++ b/crates/rustynes-ios/src/ffi.rs
@@ -97,15 +97,16 @@ pub unsafe extern "C" fn rustynes_ios_gfx_render(handle: *mut MetalGfx, fb: *con
}
// SAFETY: live handle (see above).
let gfx = unsafe { &mut *handle };
- let dst = gfx.frame_buf_mut();
- if len != dst.len() {
+ if len != crate::gfx_metal::FRAME_LEN {
return;
}
- // SAFETY: `fb` points to `len` readable bytes (caller contract); `len`
- // equals `dst.len()`, so the copy stays in bounds on both sides.
+ // SAFETY: `fb` points to `len` readable bytes (caller contract), and
+ // `len` is exactly one frame. The slice only lives for this call:
+ // `render` hands it to `write_texture`, which copies it into wgpu's
+ // staging before returning (v3.2.0; this used to copy it into a reused
+ // `frame_buf` first, one 240 KiB memcpy per frame for nothing).
let src = unsafe { core::slice::from_raw_parts(fb, len) };
- dst.copy_from_slice(src);
- gfx.render();
+ gfx.render(src);
})
}
diff --git a/crates/rustynes-ios/src/gfx_metal.rs b/crates/rustynes-ios/src/gfx_metal.rs
index 1f32d8cfa..7f9bc1f67 100644
--- a/crates/rustynes-ios/src/gfx_metal.rs
+++ b/crates/rustynes-ios/src/gfx_metal.rs
@@ -29,6 +29,9 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle, UiKitDisplayHandle, U
/// NES framebuffer dimensions (must match `rustynes_mobile::{FRAME_WIDTH,_HEIGHT}`).
const NES_W: u32 = 256;
const NES_H: u32 = 240;
+/// One RGBA8 frame, in bytes: what `rustynes_ios_gfx_render` accepts and
+/// `MetalGfx::render` uploads.
+pub(crate) const FRAME_LEN: usize = (NES_W * NES_H * 4) as usize;
/// NES pixel aspect ratio (8:7) → the displayed image is wider than 256:240.
const PAR_W: f32 = NES_W as f32 * 8.0 / 7.0;
const IMG_ASPECT: f32 = PAR_W / NES_H as f32;
@@ -92,10 +95,6 @@ pub struct MetalGfx {
hd_bind_group: Option,
/// The current HD-pack texture dimensions `(w, h)`, or `(0, 0)` if none.
hd_dims: (u32, u32),
- /// Reused framebuffer staging buffer — the FFI copies each frame's bytes in
- /// place instead of allocating a fresh `Vec` per frame. Length is fixed at
- /// `NES_W*NES_H*4`.
- frame_buf: Vec,
/// The host-owned `UIView` (`MTKView`) pointer the surface was built from.
/// The Swift side retains the view for the surface's whole lifetime; we keep
/// it only as a lifetime marker so the field drop-order intent is explicit.
@@ -456,7 +455,6 @@ impl MetalGfx {
hd_texture: None,
hd_bind_group: None,
hd_dims: (0, 0),
- frame_buf: vec![0u8; (NES_W * NES_H * 4) as usize],
_view: view,
};
gfx.write_uniforms();
@@ -531,12 +529,6 @@ impl MetalGfx {
.write_buffer(&self.uniform_buf, 0, bytemuck::bytes_of(&u));
}
- /// The reused frame staging buffer; the FFI copies the incoming RGBA bytes
- /// into this (no per-frame `Vec`). Length is fixed at `NES_W*NES_H*4`.
- pub fn frame_buf_mut(&mut self) -> &mut [u8] {
- &mut self.frame_buf
- }
-
/// Upload one 256×240 palette-index frame (`NES_W*NES_H*2` little-endian `u16`
/// bytes — `(emphasis << 6) | colour`) plus the NTSC `phase`, for the Bisqwit
/// pass. The host calls this each frame only while the Bisqwit filter is active.
@@ -567,9 +559,12 @@ impl MetalGfx {
self.write_uniforms();
}
- /// Upload the staged 256×240 RGBA frame and present it. Tolerates a transient
- /// `Lost`/`Outdated` surface by reconfiguring and skipping the frame.
- pub fn render(&mut self) {
+ /// Upload one 256×240 RGBA frame (`fb`, [`FRAME_LEN`] bytes) and present it.
+ /// Tolerates a transient `Lost`/`Outdated` surface by reconfiguring and
+ /// skipping the frame. `fb` is uploaded straight from the caller's buffer
+ /// (v3.2.0; a reused staging copy before).
+ pub fn render(&mut self, fb: &[u8]) {
+ debug_assert_eq!(fb.len(), FRAME_LEN, "one RGBA frame");
// Re-write the active filter's uniform each frame so the normal path is
// self-correcting after an HD-pack frame wrote a neutral (plain-blit)
// uniform into the shared buffer (v1.9.5). One 64-byte write — negligible.
@@ -581,7 +576,7 @@ impl MetalGfx {
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
- &self.frame_buf,
+ fb,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(NES_W * 4),
diff --git a/crates/rustynes-libretro/rustynes_libretro.info b/crates/rustynes-libretro/rustynes_libretro.info
index c5c2500c0..c60da92ed 100644
--- a/crates/rustynes-libretro/rustynes_libretro.info
+++ b/crates/rustynes-libretro/rustynes_libretro.info
@@ -5,7 +5,7 @@ supported_extensions = "nes|fds|unf|unif"
corename = "RustyNES"
license = "GPLv3+"
permissions = ""
-display_version = "v3.1.0"
+display_version = "v3.2.0"
categories = "Emulator"
# Hardware Information
@@ -35,4 +35,4 @@ firmware0_path = "disksys.rom"
firmware0_opt = "true"
notes = "(!) disksys.rom (md5): ca30b50f880eb660a320674ed365ef7a|RustyNES requires this BIOS in the system directory to boot Famicom Disk System games."
-description = "RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust, with an unsafe-free #![no_std] emulation core. Targeting the higan and Mesen2 accuracy tier, it runs from one master clock, splitting every CPU cycle into two halves and bringing the PPU up to date at each, rather than relying on per-game hacks. The core guarantees absolute determinism, making it an exceptional choice for advanced libretro features like run-ahead latency reduction, rollback netplay, and RetroAchievements (native RETRO_ENVIRONMENT_SET_MEMORY_MAPS support). With high compatibility spanning 191 mapper families, region-correct NTSC/PAL/Dendy timing, full Famicom Disk System support with in-game disk saves and labelled multi-side disk swap via the Disk Control interface, native Game Genie cheats, NES Zapper light-gun and Four Score support, and Vs. System arcade boards with their RGB palettes, coin and service inputs, RustyNES is ideal for users who seek reference-grade accuracy paired with modern stability."
+description = "RustyNES is a cycle-accurate Nintendo Entertainment System emulator written in pure Rust, with an unsafe-free #![no_std] emulation core. Targeting the higan and Mesen2 accuracy tier, it runs from one master clock, splitting every CPU cycle into two halves and bringing the PPU up to date at each, rather than relying on per-game hacks. The core guarantees absolute determinism, making it an exceptional choice for advanced libretro features like run-ahead latency reduction, rollback netplay, and RetroAchievements (native RETRO_ENVIRONMENT_SET_MEMORY_MAPS support). With high compatibility spanning 208 mapper families, region-correct NTSC/PAL/Dendy timing, full Famicom Disk System support with in-game disk saves and labelled multi-side disk swap via the Disk Control interface, native Game Genie cheats, NES Zapper light-gun and Four Score support, and Vs. System arcade boards with their RGB palettes, coin and service inputs, RustyNES is ideal for users who seek reference-grade accuracy paired with modern stability."
diff --git a/crates/rustynes-libretro/src/abi_tests.rs b/crates/rustynes-libretro/src/abi_tests.rs
index a0e17d81c..5076bdaa8 100644
--- a/crates/rustynes-libretro/src/abi_tests.rs
+++ b/crates/rustynes-libretro/src/abi_tests.rs
@@ -981,6 +981,87 @@ fn a_mid_run_round_trip_serializes_like_a_straight_run() {
);
}
+/// v3.2.0: states are SLIM (`Nes::snapshot_core_into_slim`): no 240 KiB
+/// framebuffer, which the next `retro_run` repaints. Restored into a FRESH
+/// instance -- the run-ahead "second instance" shape, and a framebuffer that
+/// starts blank -- the next frame's picture and state must equal a straight
+/// run's. A pixel the frame did not repaint would stay blank and differ.
+#[test]
+fn a_slim_state_restored_into_a_fresh_instance_draws_the_same_next_frame() {
+ let _frontend = frontend();
+ KEEP_FRAME.store(true, SeqCst);
+ assert!(load(NESTEST, true));
+ let size = serialize_size();
+ for _ in 0..10 {
+ run_frame();
+ }
+ let mut mid = vec![0_u8; size];
+ assert!(serialize(&mut mid));
+ run_frame();
+ let straight_frame = held(&LAST_FRAME).clone();
+ let mut straight = vec![0_u8; size];
+ assert!(serialize(&mut straight));
+ unload();
+
+ assert!(load(NESTEST, true));
+ assert!(
+ unserialize(&mid),
+ "a slim state loads into a fresh instance"
+ );
+ run_frame();
+ let restored_frame = held(&LAST_FRAME).clone();
+ let mut restored = vec![0_u8; size];
+ assert!(serialize(&mut restored));
+ unload();
+ KEEP_FRAME.store(false, SeqCst);
+
+ assert!(!straight_frame.is_empty(), "fixture: a frame was captured");
+ assert!(
+ restored_frame == straight_frame,
+ "the frame after a slim restore differs from a straight run's"
+ );
+ assert!(restored == straight, "and so does the state");
+ assert!(
+ size < 100_000,
+ "states are slim (no framebuffer): {size} bytes"
+ );
+}
+
+/// v3.2.0: a FULL state -- with the framebuffer, as v3.1.0's core wrote --
+/// still loads: the PPU section's version byte marks which encoding it is.
+/// Built here with `Nes::snapshot_core_into` on the same ROM, restored
+/// through `retro_unserialize`, then read back: the state the core holds is
+/// the one the full blob described.
+#[test]
+fn a_full_size_state_still_loads() {
+ let _frontend = frontend();
+ assert!(load(NESTEST, true));
+ let size = serialize_size();
+
+ let mut nes = rustynes_core::Nes::from_rom(NESTEST).expect("nestest parses");
+ for _ in 0..10 {
+ nes.run_frame();
+ }
+ let mut full = Vec::new();
+ nes.snapshot_core_into(&mut full);
+ let mut slim = Vec::new();
+ nes.snapshot_core_into_slim(&mut slim);
+ assert!(
+ full.len() > slim.len() + 200_000,
+ "fixture: the full blob carries the framebuffer"
+ );
+
+ assert!(unserialize(&full), "a full-size state is accepted");
+ let mut back = vec![0_u8; size];
+ assert!(serialize(&mut back));
+ unload();
+ assert_eq!(
+ &back[..slim.len()],
+ &slim[..],
+ "the restored machine is the one the full state described"
+ );
+}
+
#[cfg(target_arch = "x86_64")]
#[test]
fn a_refused_unserialize_logs_the_reason() {
diff --git a/crates/rustynes-libretro/src/lib.rs b/crates/rustynes-libretro/src/lib.rs
index 7cdfadf52..b0e70e94d 100644
--- a/crates/rustynes-libretro/src/lib.rs
+++ b/crates/rustynes-libretro/src/lib.rs
@@ -38,7 +38,10 @@
//! `retro_get_memory_data`/`_size` pointer API and the modern
//! `RETRO_ENVIRONMENT_SET_MEMORY_MAPS` descriptor registration RetroAchievements'
//! `rcheevos` prefers. Save states serialize statically sized binary blobs natively
-//! through `Nes::snapshot_core_into` (single console) or `VsDualSystem::snapshot_into`
+//! through `Nes::snapshot_core_into_slim` (single console; v3.2.0 -- the PPU is
+//! encoded without its 240 KiB framebuffer, which the next `retro_run` repaints
+//! before anything reads it; a full-size state, framebuffer included, is still
+//! accepted) or `VsDualSystem::snapshot_into`
//! (dual cabinet) — this is also what RetroArch's own generic rollback netplay and
//! movie/rewind features ride on; RustyNES's bespoke `rustynes-netplay` P2P crate is
//! intentionally NOT linked into this core, since it would be redundant with (and
@@ -1303,15 +1306,15 @@ impl RustyNesLibretro {
/// AUD-17). The encoder is the one `on_serialize` calls, so the size is
/// unchanged; what changes is that the first `retro_serialize` -- the
/// first frame of run-ahead or rollback netplay -- no longer grows the
- /// buffer from nothing during gameplay (about 260 KB; 645 KB for a
- /// cabinet). The single console also reserves the device headroom, so a
+ /// buffer from nothing during gameplay (about 260 KB before v3.2.0's slim
+ /// states, about 16 KB since; 645 KB for a cabinet). The single console also reserves the device headroom, so a
/// Zapper attached later does not reallocate it either. Pinned by
/// `abi_tests::no_serialize_reallocates_the_buffer_sized_at_load`.
fn size_serialize_buffer(&mut self) {
use rustynes_core::SAVE_STATE_DEVICE_HEADROOM as HEADROOM;
if let Some(nes) = self.nes.as_ref() {
self.serialize_buffer.clear();
- nes.snapshot_core_into(&mut self.serialize_buffer);
+ nes.snapshot_core_into_slim(&mut self.serialize_buffer);
self.serialize_size = self.serialize_buffer.len() + HEADROOM;
self.serialize_buffer.reserve(HEADROOM);
} else if let Some(dual) = self.dual.as_mut() {
@@ -2684,7 +2687,7 @@ impl Core for RustyNesLibretro {
let capacity = core.serialize_buffer.capacity();
if let Some(nes) = core.nes.as_ref() {
core.serialize_buffer.clear();
- nes.snapshot_core_into(&mut core.serialize_buffer);
+ nes.snapshot_core_into_slim(&mut core.serialize_buffer);
} else if let Some(dual) = core.dual.as_mut() {
dual.snapshot_into(&mut core.serialize_buffer);
} else {
diff --git a/crates/rustynes-mappers/src/bmc_simple.rs b/crates/rustynes-mappers/src/bmc_simple.rs
index 2704e4492..300e77843 100644
--- a/crates/rustynes-mappers/src/bmc_simple.rs
+++ b/crates/rustynes-mappers/src/bmc_simple.rs
@@ -403,6 +403,27 @@ impl Mapper for SimpleBmc {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (CHR_BANK_8K - 1)];
+ }
+ if self.board == SimpleBoard::M286 {
+ let slot = (addr as usize) / CHR_BANK_2K;
+ let count = (self.chr.len() / CHR_BANK_2K).max(1);
+ let bank = self.chr2[slot] % count;
+ return self.chr[bank * CHR_BANK_2K + (addr as usize & (CHR_BANK_2K - 1))];
+ }
+ let bank = self.chr8 % self.chr_count_8k();
+ self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/datach_barcode.rs b/crates/rustynes-mappers/src/datach_barcode.rs
new file mode 100644
index 000000000..661cf00d3
--- /dev/null
+++ b/crates/rustynes-mappers/src/datach_barcode.rs
@@ -0,0 +1,606 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! The Bandai Datach barcode reader: EAN / JAN symbol encoding and the
+//! deterministic serial stream the Datach main unit presents to the CPU.
+//!
+//! # What the hardware does (`nesdev_wiki/INES_Mapper_157.xhtml`)
+//!
+//! The Datach main unit plugs into the Famicom cartridge port and carries a
+//! one-dimensional barcode scanner. The page describes its interface in one
+//! sentence: "the barcode scanner just returns a sequence of light and dark
+//! periods from a one-dimesional barcode as the user moves it past the
+//! sensor", read in **bit 3** of any CPU read of `$6000-$7FFF` (mask `$E000`;
+//! bit 4 of the same read is the serial EEPROMs' data line). There is no
+//! command register, no handshake and no buffering: the CPU samples one level
+//! at a time and measures the widths of the runs itself. The page also says
+//! "the game only expects UPCs to be scanned, so an emulator may wish to
+//! provide an interface to convert a series of digits into a UPC". The cards
+//! Bandai printed for the Datach carry JAN codes, which are EAN-13 / EAN-8
+//! symbols (the UPC family's 13- and 8-digit forms), so that is what this
+//! module draws.
+//!
+//! # The symbol (public EAN / JAN specification)
+//!
+//! The digit patterns, guard bars and first-digit parity table below are the
+//! published EAN-13 / EAN-8 symbology (GS1 General Specifications; the same
+//! tables appear in every reference on the symbology). A symbol is a sequence
+//! of equal-width **modules**, each either dark (a bar) or light (a space):
+//!
+//! - EAN-13: start guard `101`, six left-half digits (each 7 modules, drawn
+//! from the L or G set as the first digit's parity pattern selects), centre
+//! guard `01010`, six right-half digits (R set), end guard `101`: **95**
+//! modules. The first digit has no bars of its own; it is encoded only in
+//! the L/G parity pattern of digits 2-7.
+//! - EAN-8: start guard, four L digits, centre guard, four R digits, end
+//! guard: **67** modules.
+//!
+//! The last digit of either form is a check digit: weight the other digits
+//! 3, 1, 3, 1, ... from the right, sum, and the check digit brings the sum to
+//! a multiple of ten. [`Barcode::parse`] rejects a code whose check digit is
+//! wrong, with the digit it expected, so a mistyped card number fails at the
+//! boundary instead of inside the game.
+//!
+//! # The serial stream (this project's model; the page gives no timing)
+//!
+//! The page states neither the polarity of bit 3 nor any timing, and no other
+//! public document found does either (the `NESdev` wiki and forum searched
+//! 2026-10-08). The choices here were therefore settled **black-box, against
+//! the games themselves**, by presenting candidate streams to the staged
+//! Datach dumps at a screen waiting for a card and watching what the game
+//! did. No other emulator's source was consulted (the page's FCEUX link was
+//! deliberately not followed). The model and the measurements behind it:
+//!
+//! - **Polarity:** bit 3 reads **1 while the sensor sees light** (a space or
+//! the card's white margin) and **0 over a dark bar** ([`DARK_LEVEL`]).
+//! With the opposite polarity none of the three games that were brought to
+//! a scan prompt reacted to any code at any speed.
+//! - **Idle:** with no card passing, bit 3 reads the **dark** level, 0: an
+//! empty slot reflects no light. Idle-light was tried first and *Battle
+//! Rush* then showed only one of its three title-menu items and ignored the
+//! A button; with idle-dark it shows all three and proceeds.
+//! - **Timing:** every module lasts [`MODULE_CYCLES`] CPU cycles, and the
+//! symbol is framed by [`QUIET_MODULES`] light modules before and after
+//! (the card's white margin, which includes the symbology's quiet zones).
+//! The window each game accepted, with these margins: *Dragon Ball Z:
+//! Gekitou Tenkaichi Budoukai* (Karin's room) 200-3,000 cycles per module,
+//! *SD Gundam Wars* (Barcode Lab) 200-3,000, *J.League Super Top Players*
+//! (team select) 400-8,000; 5,000 and 8,000 failed in the first two and
+//! 100 in all three. 1,000 sits inside all three windows, and a whole
+//! EAN-13 swipe then takes (95 + 2 x 128) x 1,000 = 351,000 cycles, about
+//! 0.2 s, a plausible hand swipe. Shorter margins narrowed the windows:
+//! with 32 light modules *Dragon Ball Z* needed at least 700 cycles per
+//! module. A real swipe is neither uniform nor exactly this speed; a
+//! uniform, deterministic sweep is what a reproducible emulator can offer,
+//! and the games measure run widths relative to one another.
+//!
+//! Time is counted in CPU cycles, never wall-clock time: the stream advances
+//! from the mapper's per-M2 hook, so a scan is part of the deterministic
+//! emulation (same scan at the same cycle, same frames) and its in-flight
+//! position is part of the mapper's save state.
+
+use alloc::vec::Vec;
+use core::fmt;
+
+/// Bit 3 of a `$6000-$7FFF` read while the sensor sees a **dark** module
+/// (a bar) or no card at all (idle). A light module (a space or the card's
+/// margin) reads the opposite level.
+///
+/// Measured, not documented: see the module preamble.
+pub const DARK_LEVEL: bool = false;
+
+/// CPU cycles each module of the symbol is presented for.
+///
+/// Measured, not documented: see the module preamble and
+/// `docs/mappers.md` (mapper 157) for the sweep that settled it.
+pub const MODULE_CYCLES: u32 = 1000;
+
+/// Light modules presented before and after the symbol (the card's margin).
+///
+/// The margin contains the symbology's quiet zones (at least 11 modules left
+/// and 7 right of an EAN-13). Measured, not documented: see the module
+/// preamble.
+pub const QUIET_MODULES: u32 = 128;
+
+/// Why a barcode string was refused at the boundary.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BarcodeError {
+ /// The string held no digits at all.
+ Empty,
+ /// A character other than an ASCII digit, at this character index
+ /// (spaces and hyphens, which cards print as separators, are accepted
+ /// and skipped).
+ NotADigit {
+ /// Character index in the input string.
+ index: usize,
+ /// The offending character.
+ found: char,
+ },
+ /// Neither 13 (EAN-13 / JAN-13) nor 8 (EAN-8 / JAN-8) digits.
+ WrongLength(usize),
+ /// The last digit is not the check digit the others require.
+ BadCheckDigit {
+ /// The check digit the first 12 / 7 digits require.
+ expected: u8,
+ /// The check digit the string carried.
+ found: u8,
+ },
+}
+
+impl fmt::Display for BarcodeError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match *self {
+ Self::Empty => write!(f, "no digits"),
+ Self::NotADigit { index, found } => {
+ write!(f, "character {index} ('{found}') is not a digit")
+ }
+ Self::WrongLength(n) => write!(f, "{n} digits; a JAN/EAN code has 13 or 8"),
+ Self::BadCheckDigit { expected, found } => {
+ write!(f, "check digit is {found}, expected {expected}")
+ }
+ }
+ }
+}
+
+/// A validated EAN-13 or EAN-8 (JAN) code.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct Barcode {
+ digits: [u8; 13],
+ len: u8,
+}
+
+/// Left-half odd-parity ("L") digit patterns, most significant bit first,
+/// 1 = dark module.
+const L_CODES: [u8; 10] = [
+ 0b000_1101, 0b001_1001, 0b001_0011, 0b011_1101, 0b010_0011, 0b011_0001, 0b010_1111, 0b011_1011,
+ 0b011_0111, 0b000_1011,
+];
+
+/// Right-half ("R") patterns: the bitwise complement of the L patterns.
+const fn r_code(d: u8) -> u8 {
+ !L_CODES[d as usize] & 0x7F
+}
+
+/// Left-half even-parity ("G") patterns: the R patterns mirrored.
+const fn g_code(d: u8) -> u8 {
+ r_code(d).reverse_bits() >> 1
+}
+
+/// EAN-13 first-digit parity: for digits 2-7, bit 5 (digit 2) .. bit 0
+/// (digit 7), set where the digit uses the G set.
+const FIRST_DIGIT_PARITY: [u8; 10] = [
+ 0b00_0000, 0b00_1011, 0b00_1101, 0b00_1110, 0b01_0011, 0b01_1001, 0b01_1100, 0b01_0101,
+ 0b01_0110, 0b01_1010,
+];
+
+impl Barcode {
+ /// Parse and validate a 13- or 8-digit code. Spaces and hyphens are
+ /// skipped (cards print the digits in groups).
+ ///
+ /// # Errors
+ ///
+ /// [`BarcodeError`] for an empty string, a non-digit character, a digit
+ /// count other than 13 or 8, or a wrong check digit.
+ pub fn parse(text: &str) -> Result {
+ let mut digits = [0u8; 13];
+ let mut len = 0usize;
+ for (index, c) in text.chars().enumerate() {
+ match c {
+ ' ' | '-' => {}
+ '0'..='9' => {
+ if len < digits.len() {
+ digits[len] = c as u8 - b'0';
+ }
+ len += 1;
+ }
+ found => return Err(BarcodeError::NotADigit { index, found }),
+ }
+ }
+ match len {
+ 0 => return Err(BarcodeError::Empty),
+ 8 | 13 => {}
+ n => return Err(BarcodeError::WrongLength(n)),
+ }
+ let expected = check_digit(&digits[..len - 1]);
+ let found = digits[len - 1];
+ if expected != found {
+ return Err(BarcodeError::BadCheckDigit { expected, found });
+ }
+ // `len` is 8 or 13 here.
+ let len = u8::try_from(len).map_err(|_| BarcodeError::WrongLength(len))?;
+ Ok(Self { digits, len })
+ }
+
+ /// The digits, 13 or 8 of them.
+ #[must_use]
+ pub fn digits(&self) -> &[u8] {
+ &self.digits[..usize::from(self.len)]
+ }
+
+ /// The symbol's modules, left to right, `true` = dark (a bar): 95 for
+ /// EAN-13, 67 for EAN-8. Quiet zones are not included.
+ #[must_use]
+ pub fn modules(&self) -> Vec {
+ let mut out = Vec::with_capacity(95);
+ let mut push = |pattern: u8, width: u32| {
+ for i in (0..width).rev() {
+ out.push(pattern >> i & 1 != 0);
+ }
+ };
+ let d = self.digits();
+ push(0b101, 3);
+ if d.len() == 13 {
+ let parity = FIRST_DIGIT_PARITY[usize::from(d[0])];
+ for (i, &digit) in d[1..7].iter().enumerate() {
+ let g = parity >> (5 - i) & 1 != 0;
+ push(
+ if g {
+ g_code(digit)
+ } else {
+ L_CODES[usize::from(digit)]
+ },
+ 7,
+ );
+ }
+ push(0b01010, 5);
+ for &digit in &d[7..13] {
+ push(r_code(digit), 7);
+ }
+ } else {
+ for &digit in &d[..4] {
+ push(L_CODES[usize::from(digit)], 7);
+ }
+ push(0b01010, 5);
+ for &digit in &d[4..8] {
+ push(r_code(digit), 7);
+ }
+ }
+ push(0b101, 3);
+ out
+ }
+}
+
+impl fmt::Display for Barcode {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ for &d in self.digits() {
+ write!(f, "{d}")?;
+ }
+ Ok(())
+ }
+}
+
+/// The EAN check digit for `body` (the digits before it): weights 3, 1, 3,
+/// ... from the right.
+fn check_digit(body: &[u8]) -> u8 {
+ let sum: u32 = body
+ .iter()
+ .rev()
+ .enumerate()
+ .map(|(i, &d)| u32::from(d) * if i % 2 == 0 { 3 } else { 1 })
+ .sum();
+ ((10 - sum % 10) % 10) as u8
+}
+
+/// The scanner's serial output as the CPU sees it, advanced one CPU cycle
+/// at a time.
+///
+/// Holds the modules of the scan in flight (quiet zones included), the
+/// index of the module now under the sensor, and the cycles left on it.
+/// Idle (`modules` empty) it reads the dark level.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct BarcodeReader {
+ /// Levels of the scan in flight, `true` = dark, quiet zones included.
+ modules: Vec,
+ /// Index into `modules` of the module under the sensor.
+ pos: u32,
+ /// CPU cycles left before `pos` advances.
+ remaining: u32,
+}
+
+impl BarcodeReader {
+ /// Begin presenting `code`, replacing any scan in flight. The first
+ /// light quiet-zone module starts on the next CPU cycle.
+ pub fn scan(&mut self, code: &Barcode) {
+ let quiet = QUIET_MODULES as usize;
+ let mut modules = Vec::with_capacity(2 * quiet + 95);
+ modules.resize(quiet, false);
+ modules.extend(code.modules());
+ modules.resize(modules.len() + quiet, false);
+ self.modules = modules;
+ self.pos = 0;
+ self.remaining = MODULE_CYCLES;
+ }
+
+ /// Whether a scan is in flight.
+ #[must_use]
+ pub const fn is_scanning(&self) -> bool {
+ !self.modules.is_empty()
+ }
+
+ /// Advance one CPU cycle. Ends the scan after the last quiet module.
+ pub fn tick(&mut self) {
+ if self.modules.is_empty() {
+ return;
+ }
+ self.remaining -= 1;
+ if self.remaining == 0 {
+ self.pos += 1;
+ if self.pos as usize >= self.modules.len() {
+ self.modules.clear();
+ self.pos = 0;
+ } else {
+ self.remaining = MODULE_CYCLES;
+ }
+ }
+ }
+
+ /// Bit 3 of a `$6000-$7FFF` read: [`DARK_LEVEL`] over a bar and when
+ /// idle, its complement over a space or the card's margin.
+ #[must_use]
+ pub fn level(&self) -> bool {
+ // Idle: no card in the slot, nothing reflects, the sensor sees dark.
+ let dark = self.modules.get(self.pos as usize).copied().unwrap_or(true);
+ if dark { DARK_LEVEL } else { !DARK_LEVEL }
+ }
+
+ /// Append the reader's state to a mapper save-state blob:
+ /// `u16` module count, `u32` position, `u32` cycles left, then one byte
+ /// per eight modules (LSB first).
+ pub fn save(&self, out: &mut Vec) {
+ // At most 95 + 2 * QUIET_MODULES modules, far inside `u16`.
+ let count = u16::try_from(self.modules.len()).unwrap_or(u16::MAX);
+ out.extend_from_slice(&count.to_le_bytes());
+ out.extend_from_slice(&self.pos.to_le_bytes());
+ out.extend_from_slice(&self.remaining.to_le_bytes());
+ for chunk in self.modules.chunks(8) {
+ let mut b = 0u8;
+ for (i, &m) in chunk.iter().enumerate() {
+ b |= u8::from(m) << i;
+ }
+ out.push(b);
+ }
+ }
+
+ /// Restore from the front of `data` (the layout [`Self::save`] writes),
+ /// returning the bytes consumed, or `None` if `data` is too short or
+ /// describes an impossible position.
+ pub fn load(&mut self, data: &[u8]) -> Option {
+ let header = data.get(..10)?;
+ let count = usize::from(u16::from_le_bytes([header[0], header[1]]));
+ let pos = u32::from_le_bytes([header[2], header[3], header[4], header[5]]);
+ let remaining = u32::from_le_bytes([header[6], header[7], header[8], header[9]]);
+ // `scan` builds an EAN-8 (67 modules) or EAN-13 (95) symbol between two
+ // quiet zones and `tick` empties the list when the scan ends, so those
+ // are the only lengths a reader can hold. Anything else is a corrupt
+ // or hostile blob, refused before anything changes (CodeRabbit on
+ // #607: it used to accept any count up to 65,535).
+ let quiet = 2 * QUIET_MODULES as usize;
+ if count != 0 && count != 67 + quiet && count != 95 + quiet {
+ return None;
+ }
+ let bytes = count.div_ceil(8);
+ let packed = data.get(10..10 + bytes)?;
+ if count > 0 && (pos as usize >= count || remaining == 0 || remaining > MODULE_CYCLES) {
+ return None;
+ }
+ self.modules = (0..count)
+ .map(|i| packed[i / 8] >> (i % 8) & 1 != 0)
+ .collect();
+ self.pos = if count == 0 { 0 } else { pos };
+ self.remaining = if count == 0 { 0 } else { remaining };
+ Some(10 + bytes)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use alloc::format;
+ use alloc::string::String;
+ use alloc::vec::Vec;
+
+ #[test]
+ fn a_saved_scan_of_any_other_length_is_refused() {
+ let mut r = BarcodeReader::default();
+ r.scan(&Barcode::parse("4006381333931").unwrap());
+ let mut blob = Vec::new();
+ r.save(&mut blob);
+ let mut fresh = BarcodeReader::default();
+ assert_eq!(fresh.load(&blob), Some(blob.len()), "a real scan loads");
+ // 1,000 modules, with packed bytes to match, so only the count is wrong.
+ let mut bad = Vec::new();
+ bad.extend_from_slice(&1000u16.to_le_bytes());
+ bad.extend_from_slice(&0u32.to_le_bytes());
+ bad.extend_from_slice(&MODULE_CYCLES.to_le_bytes());
+ bad.resize(10 + 1000usize.div_ceil(8), 0);
+ let mut other = BarcodeReader::default();
+ assert_eq!(other.load(&bad), None);
+ assert!(!other.is_scanning(), "nothing restored");
+ }
+
+ fn as_text(modules: &[bool]) -> String {
+ modules.iter().map(|&m| if m { '1' } else { '0' }).collect()
+ }
+
+ #[test]
+ fn check_digit_is_validated_with_the_digit_it_expected() {
+ // 4006381333931: the symbology's customary worked example.
+ assert!(Barcode::parse("4006381333931").is_ok());
+ assert_eq!(
+ Barcode::parse("4006381333932"),
+ Err(BarcodeError::BadCheckDigit {
+ expected: 1,
+ found: 2
+ })
+ );
+ // EAN-8 96385074: weights 3,1,3,1,3,1,3 over 9638507 = 27+6+9+8+15+0+21 = 86.
+ assert!(Barcode::parse("96385074").is_ok());
+ assert_eq!(
+ Barcode::parse("96385075"),
+ Err(BarcodeError::BadCheckDigit {
+ expected: 4,
+ found: 5
+ })
+ );
+ }
+
+ #[test]
+ fn malformed_strings_are_refused_with_a_typed_reason() {
+ assert_eq!(Barcode::parse(""), Err(BarcodeError::Empty));
+ assert_eq!(Barcode::parse(" - "), Err(BarcodeError::Empty));
+ assert_eq!(
+ Barcode::parse("40063813339x1"),
+ Err(BarcodeError::NotADigit {
+ index: 11,
+ found: 'x'
+ })
+ );
+ assert_eq!(
+ Barcode::parse("123456789012"),
+ Err(BarcodeError::WrongLength(12))
+ );
+ assert_eq!(
+ Barcode::parse("12345678901234"),
+ Err(BarcodeError::WrongLength(14))
+ );
+ // Grouping separators are skipped.
+ assert_eq!(
+ Barcode::parse("4 006381 333931").unwrap().digits(),
+ &[4, 0, 0, 6, 3, 8, 1, 3, 3, 3, 9, 3, 1]
+ );
+ }
+
+ /// The EAN-13 symbol for 4006381333931, written out by hand from the
+ /// published tables (first digit 4 selects parity LGLLGG for digits 2-7):
+ ///
+ /// ```text
+ /// 101 start
+ /// 0 L 0001101 0 G 0100111 6 L 0101111
+ /// 3 L 0111101 8 G 0001001 1 G 0110011
+ /// 01010 centre
+ /// 3 R 1000010 3 R 1000010 3 R 1000010
+ /// 9 R 1110100 3 R 1000010 1 R 1100110
+ /// 101 end
+ /// ```
+ #[test]
+ fn ean13_modules_match_the_published_tables() {
+ let code = Barcode::parse("4006381333931").unwrap();
+ let want = concat!(
+ "101", "0001101", "0100111", "0101111", "0111101", "0001001", "0110011", "01010",
+ "1000010", "1000010", "1000010", "1110100", "1000010", "1100110", "101",
+ );
+ assert_eq!(as_text(&code.modules()), want);
+ assert_eq!(want.len(), 95);
+ }
+
+ /// Every digit in every set, typed out from the published EAN tables
+ /// (L = odd parity left half, G = even parity left half, R = right half),
+ /// and the first-digit parity table. The worked examples below exercise
+ /// only some digits per set; this pins all thirty patterns and all ten
+ /// parity rows, so a corrupted table entry cannot hide behind a code
+ /// that happens not to use it.
+ #[test]
+ fn every_digit_pattern_and_parity_row_matches_the_published_tables() {
+ const L: [&str; 10] = [
+ "0001101", "0011001", "0010011", "0111101", "0100011", "0110001", "0101111", "0111011",
+ "0110111", "0001011",
+ ];
+ const G: [&str; 10] = [
+ "0100111", "0110011", "0011011", "0100001", "0011101", "0111001", "0000101", "0010001",
+ "0001001", "0010111",
+ ];
+ const R: [&str; 10] = [
+ "1110010", "1100110", "1101100", "1000010", "1011100", "1001110", "1010000", "1000100",
+ "1001000", "1110100",
+ ];
+ const PARITY: [&str; 10] = [
+ "LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG", "LGGLLG", "LGGGLL", "LGLGLG",
+ "LGLGGL", "LGGLGL",
+ ];
+ let bits = |p: u8| -> String {
+ (0..7)
+ .rev()
+ .map(|i| if p >> i & 1 != 0 { '1' } else { '0' })
+ .collect()
+ };
+ for d in 0..10u8 {
+ assert_eq!(bits(L_CODES[usize::from(d)]), L[usize::from(d)], "L {d}");
+ assert_eq!(bits(g_code(d)), G[usize::from(d)], "G {d}");
+ assert_eq!(bits(r_code(d)), R[usize::from(d)], "R {d}");
+ let row: String = (0..6)
+ .map(|i| {
+ if FIRST_DIGIT_PARITY[usize::from(d)] >> (5 - i) & 1 != 0 {
+ 'G'
+ } else {
+ 'L'
+ }
+ })
+ .collect();
+ assert_eq!(row, PARITY[usize::from(d)], "parity row {d}");
+ }
+ }
+
+ /// EAN-8 96385074: start, L 9 6 3 8, centre, R 5 0 7 4, end.
+ #[test]
+ fn ean8_modules_match_the_published_tables() {
+ let code = Barcode::parse("96385074").unwrap();
+ let want = concat!(
+ "101", "0001011", "0101111", "0111101", "0110111", "01010", "1001110", "1110010",
+ "1000100", "1011100", "101",
+ );
+ assert_eq!(as_text(&code.modules()), want);
+ assert_eq!(want.len(), 67);
+ }
+
+ /// The stream the CPU samples: quiet light, the symbol module by module,
+ /// quiet light, then idle -- each module exactly [`MODULE_CYCLES`] cycles,
+ /// read through [`DARK_LEVEL`].
+ #[test]
+ fn the_reader_presents_each_module_for_module_cycles() {
+ let code = Barcode::parse("96385074").unwrap();
+ let mut r = BarcodeReader::default();
+ assert!(!r.is_scanning());
+ assert_eq!(r.level(), DARK_LEVEL, "idle reads dark: an empty slot");
+ r.scan(&code);
+ let mut seen = String::new();
+ let mut cycles = 0u32;
+ while r.is_scanning() {
+ if cycles.is_multiple_of(MODULE_CYCLES) {
+ seen.push(if r.level() == DARK_LEVEL { '1' } else { '0' });
+ } else {
+ // Within a module the level never changes.
+ assert_eq!(seen.ends_with('1'), r.level() == DARK_LEVEL);
+ }
+ r.tick();
+ cycles += 1;
+ }
+ let quiet = "0".repeat(QUIET_MODULES as usize);
+ assert_eq!(seen, format!("{quiet}{}{quiet}", as_text(&code.modules())));
+ assert_eq!(cycles, (67 + 2 * QUIET_MODULES) * MODULE_CYCLES);
+ assert_eq!(r.level(), DARK_LEVEL, "idle (dark) again after the scan");
+ }
+
+ #[test]
+ fn reader_state_round_trips_mid_scan_and_refuses_garbage() {
+ let mut r = BarcodeReader::default();
+ r.scan(&Barcode::parse("4006381333931").unwrap());
+ for _ in 0..(40 * MODULE_CYCLES + 17) {
+ r.tick();
+ }
+ let mut blob = Vec::new();
+ r.save(&mut blob);
+ let mut back = BarcodeReader::default();
+ assert_eq!(back.load(&blob), Some(blob.len()));
+ assert_eq!(back, r);
+ // A position past the end is refused.
+ let mut bad = blob.clone();
+ bad[2..6].copy_from_slice(&10_000u32.to_le_bytes());
+ assert_eq!(BarcodeReader::default().load(&bad), None);
+ assert_eq!(BarcodeReader::default().load(&blob[..5]), None);
+ // An idle reader round-trips to idle.
+ let mut idle = Vec::new();
+ BarcodeReader::default().save(&mut idle);
+ assert_eq!(idle.len(), 10);
+ let mut back = r.clone();
+ assert_eq!(back.load(&idle), Some(10));
+ assert!(!back.is_scanning());
+ }
+}
diff --git a/crates/rustynes-mappers/src/fds.rs b/crates/rustynes-mappers/src/fds.rs
index d26ed648b..268aacda0 100644
--- a/crates/rustynes-mappers/src/fds.rs
+++ b/crates/rustynes-mappers/src/fds.rs
@@ -1051,6 +1051,11 @@ impl FdsAudio {
// level minus its midpoint, scaled to use a good chunk of i16 range.
// Midpoint of sample range is 31.5; approximate the DC bias as
// 32 * gain * num / den (sample == 32).
+ //
+ // The level and the midpoint are each floored SEPARATELY. A v3.2.0
+ // import folded them into one `(sample - 32) * gain * 2 / den`, which
+ // truncates toward zero instead and moved every FDS game's audio
+ // (`fds_output_floors_level_and_midpoint_separately` pins it).
let mid = 32u32 * gain * num / den;
let centered = scaled as i32 - mid as i32;
// Scale factor 7: peak |centered| ~= 31 * 32 ~= 992 -> *7 ~= 6944 per
@@ -2281,6 +2286,13 @@ impl Mapper for Fds {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[(addr & 0x1FFF) as usize],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
if addr <= 0x1FFF {
self.chr_ram[(addr & 0x1FFF) as usize] = value;
@@ -3314,6 +3326,30 @@ mod tests {
assert_eq!(fds.audio.mod_counter, -1);
}
+ /// The output floors the level and the midpoint separately. With master
+ /// volume 2/3 the two roundings differ: sample 63 at gain 1 is
+ /// `63*2/3 - 32*2/3 = 42 - 21 = 21` (x7 = 147), where a single combined
+ /// division gives `31*2/3 = 20` (x7 = 140). The combined form is what a
+ /// v3.2.0 import shipped; it moved all three staged FDS games' audio.
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ #[allow(clippy::field_reassign_with_default)]
+ fn fds_output_floors_level_and_midpoint_separately() {
+ let mut a = FdsAudio::default();
+ a.vol_gain = 1;
+ a.master_volume = 1;
+ a.wave_out_latch = 63;
+ assert_eq!(a.output(), 147);
+ // Below the midpoint: 0 - 21 = -21 either way (x7 = -147).
+ a.wave_out_latch = 0;
+ assert_eq!(a.output(), -147);
+ // Full gain, full master: (63*32 - 32*32) = 992, x7 = 6944.
+ a.vol_gain = 32;
+ a.master_volume = 0;
+ a.wave_out_latch = 63;
+ assert_eq!(a.output(), 6944);
+ }
+
#[test]
#[cfg(feature = "mapper-audio")]
#[allow(clippy::field_reassign_with_default)]
diff --git a/crates/rustynes-mappers/src/ffe_super_magic_card.rs b/crates/rustynes-mappers/src/ffe_super_magic_card.rs
new file mode 100644
index 000000000..815d8339e
--- /dev/null
+++ b/crates/rustynes-mappers/src/ffe_super_magic_card.rs
@@ -0,0 +1,1103 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Mappers 6, 8 and 17: games extracted from Front Fareast (FFE) Magic Card
+//! and Super Magic Card disks (v3.2.0).
+//!
+//! Written from the vendored `NESdev` pages `INES_Mapper_006`,
+//! `INES_Mapper_008`, `INES_Mapper_017` and the page they all point to for the
+//! hardware, `Super_Magic_Card`.
+//!
+//! These are not cartridge boards. They are copier RAM cartridges: the game
+//! is loaded from a floppy into the Super Magic Card's 512 KiB of PRG DRAM and
+//! 256 KiB of CHR-RAM, then run in "Play mode". The three iNES numbers only
+//! say which state the card's registers start in:
+//!
+//! | iNES | `$4500` | `$42FF` (1M mode) | 2M/4M | other |
+//! |---|---|---|---|---|
+//! | 6, submapper *n* (iNES 1.0 = 1) | `$42` | `n << 5`, H/V from the header | disabled (`$43FF = $00`) | |
+//! | 8 | as 6 submapper 4 ("a synonym of Mapper 6 submapper 4") | | | |
+//! | 17 | `$47` (1 KiB CHR) | `$20`, H/V | 4M (`$43FC = $00`) | `$4504-$4507` = the last four 8 KiB banks |
+//!
+//! This module models the card in Play mode, which is all an extracted game
+//! uses. GUI mode, pass-through mode, the parallel port and the floppy
+//! controller are the card's own firmware's business and are not modelled; a
+//! write that leaves Play mode is stored but changes nothing.
+//!
+//! ## Memory (Play mode)
+//!
+//! - `$5000-$5FFF`: 4 KiB scratch RAM.
+//! - `$6000-$7FFF`: an 8 KiB window into 32 KiB WRAM (`$4500` bits 4-5).
+//! - `$8000-$FFFF`: a 32 KiB view of the 512 KiB PRG DRAM, banked by one of
+//! the eight latch modes, the 2M mode or the 4M mode. With the DRAM
+//! write-enabled (`$42Fx` A1 = 0) a CPU write there stores into the DRAM and
+//! the latch is off.
+//! - `$0000-$1FFF`: CHR-RAM in one 8 KiB bank of four, or eight 1 KiB banks
+//! (`$4510-$4517`) in 1 KiB mode, or MMC4-style 4 KiB banks.
+//! - Nametables: CIRAM with the `$42Fx` mirroring (1scA, 1scB, V, H), or, with
+//! `$4500` bit 1 clear, four 1 KiB CHR-RAM pages (`$4518-$451B`).
+//!
+//! The image's PRG is loaded at the start of the DRAM and its CHR at the start
+//! of the CHR-RAM, as the copier would.
+//!
+//! ## Registers
+//!
+//! `$42FC-$42FF` (A1 = protect, A0 and D4 = mirroring type `A0 << 1 | D4`,
+//! D7-D5 = latch mode); `$43FC-$43FF` (A0 = 2M/4M off, A1 = 2M rather than
+//! 4M, D1-D0 = CHR bank); `$4500` (mode: CHR 1 KiB, CIRAM nametables, MMC4
+//! off, IRQ source, WRAM bank, Play, pass-through); `$4501` IRQ off and
+//! acknowledge; `$4502`/`$4503` IRQ counter, `$4503` enabling it; `$4504-$4507`
+//! 4M PRG banks; `$4510-$451B` 1 KiB CHR and nametable banks. Reads: `$4500`
+//! and `$4501` state registers. The mirroring-type encoding is taken from the
+//! two initial values the mapper pages give: `$42FF` with D4 = 0 is vertical
+//! (type 2) and with D4 = 1 horizontal (type 3), which only `A0 << 1 | D4`
+//! satisfies.
+//!
+//! ## IRQs
+//!
+//! The card's 16-bit up-counter, clocked by M2 or by every PPU A12 rise
+//! (unfiltered), raises an IRQ when it wraps from `$FFFF` to `$0000`. And
+//! because the older Magic Cards had no counter, "a few games abuse the FDS
+//! Disk Data IRQ": with `$4025` bit 7 set the FDS RAM adapter the card sits on
+//! raises an IRQ every 1792 master clocks (149 1/3 CPU cycles), acknowledged by
+//! a `$4024` write. Both are modelled, since the adapter is always present
+//! under the card.
+//!
+//! ## Trainers
+//!
+//! A Doctor-format trainer arrives as the iNES trainer. Mapper 6 loads it at
+//! `$7000-$71FF` (WRAM bank 0) and, on a hard reset, "JSRs to `$7003` before
+//! `JMPing` to the game's reset vector". Mapper 17 loads it at `$7000`,
+//! `$5D00`, `$5E00` or `$5F00` (NES 2.0 submapper 0-3) and JMPs to it instead
+//! of the reset vector. The card's BIOS did this; here the board answers the
+//! power-on reset-vector fetch with the trainer's address (mapper 17), or with
+//! a six-byte overlay at `$7200` holding `JSR $7003 / JMP ($FFFC)` that
+//! disappears once its last byte has been fetched (mapper 6). A soft reset
+//! (`Mapper::reset`) uses the ordinary vector, as the page specifies the call
+//! only "on a hard reset".
+//!
+//! ## What the pages leave open
+//!
+//! - The initial `$4510-$4517` values for mapper 17 (not given): 0-7.
+//! - What "the 4M registers function as mirrors of the 2M banking mode's
+//! registers" when 4M mode is off means: a `$4504-$4507` write then also
+//! loads the matching 2M register's PRG bank. In 4M mode, `$8000-$FFFF`
+//! writes still load the 2M registers, which "accept values even when ...
+//! not active", without moving the 4M banks.
+//! - Whether the 2M registers latch while the DRAM is write-enabled: they do
+//! not, like the latch they overlap.
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::missing_const_for_fn,
+ clippy::struct_excessive_bools,
+ clippy::too_many_lines
+)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_DRAM: usize = 512 * 1024;
+const CHR_RAM: usize = 256 * 1024;
+const WRAM: usize = 32 * 1024;
+const SCRATCH: usize = 4 * 1024;
+const BANK_8K: usize = 0x2000;
+const BANK_16K: usize = 0x4000;
+const BANK_32K: usize = 0x8000;
+const TRAINER_LEN: usize = 512;
+/// The mapper 6 hard-reset stub's address: just past the trainer.
+const STUB_ADDR: u16 = 0x7200;
+/// `JSR $7003 ; JMP ($FFFC)`.
+const STUB: [u8; 6] = [0x20, 0x03, 0x70, 0x6C, 0xFC, 0xFF];
+/// The FDS adapter's disk IRQ period, 1792 master clocks, in thirds of a CPU
+/// cycle (12 master clocks per CPU cycle: 1792 / 12 = 448 / 3).
+const FDS_IRQ_THIRDS: u16 = 448;
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// Which iNES number the image carried (it decides only the initial state).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FfeBoard {
+ /// Mapper 6 with the given latch mode (NES 2.0 submapper; 1 on iNES 1.0).
+ /// Mapper 8 is mapper 6 with latch mode 4.
+ MagicCard(u8),
+ /// Mapper 17 with the given trainer-address submapper (0-3).
+ SuperMagicCard(u8),
+}
+
+/// The Super Magic Card in Play mode. See the module docs.
+pub struct SuperMagicCard {
+ board: FfeBoard,
+ prg: Box<[u8]>,
+ chr: Box<[u8]>,
+ wram: Box<[u8]>,
+ scratch: Box<[u8]>,
+ // $42Fx: latch mode, write-protect, mirroring type.
+ latch_mode: u8,
+ prg_protected: bool,
+ mirroring_type: u8,
+ /// The latch at `$8000-$FFFF` (latch-based modes).
+ latch: u8,
+ // $43Fx.
+ mode_2m4m_enabled: bool,
+ mode_2m: bool,
+ /// The common 8 KiB CHR bank (`CC`) used by the 2M and 4M modes.
+ chr_cc: u8,
+ /// 2M registers (`$8000/$A000/$C000/$E000`, D7-D2).
+ reg_2m: [u8; 4],
+ /// 4M registers (`$4504-$4507`, D5-D0).
+ reg_4m: [u8; 4],
+ /// `$4500`.
+ smc_mode: u8,
+ /// `$4510-$451B`: eight CHR and four nametable 1 KiB banks.
+ chr_1k: [u8; 12],
+ /// MMC4 latches for `$0000-$0FFF` and `$1000-$1FFF` (`false` = `$FD`).
+ mmc4_latch: [bool; 2],
+ // IRQ counter.
+ irq_counter: u16,
+ irq_enabled: bool,
+ irq_pending: bool,
+ // FDS adapter disk IRQ.
+ fds_irq_enabled: bool,
+ fds_irq_thirds: u16,
+ fds_irq_pending: bool,
+ /// Power-on reset vector override (the trainer address, or the stub).
+ boot_vector: Option,
+ /// The mapper 6 stub is mapped at `$7200` until its last byte is fetched.
+ stub_live: bool,
+ last_a12: bool,
+}
+
+impl SuperMagicCard {
+ /// Build the card for `board`, loading the image's PRG into the DRAM, its
+ /// CHR into the CHR-RAM and its trainer (if any) where the board's page
+ /// says, then setting the registers to the page's initial values.
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when the PRG image is empty, larger than the
+ /// 512 KiB DRAM or not a multiple of 8 KiB, the CHR image is larger than
+ /// the 256 KiB CHR-RAM, or a trainer is shorter than its 512 bytes.
+ pub fn new(
+ board: FfeBoard,
+ prg_rom: &[u8],
+ chr_rom: &[u8],
+ trainer: Option<&[u8]>,
+ vertical: bool,
+ ) -> Result {
+ if prg_rom.is_empty() || prg_rom.len() > PRG_DRAM || !prg_rom.len().is_multiple_of(BANK_8K)
+ {
+ return Err(MapperError::Invalid(format!(
+ "FFE PRG image size {} is not 8 KiB-aligned within the card's 512 KiB",
+ prg_rom.len()
+ )));
+ }
+ if chr_rom.len() > CHR_RAM {
+ return Err(MapperError::Invalid(format!(
+ "FFE CHR image size {} exceeds the card's 256 KiB of CHR-RAM",
+ chr_rom.len()
+ )));
+ }
+ // `parse` always passes exactly 512 bytes, but `new` is public, and both
+ // board arms slice `t[..TRAINER_LEN]` (CodeRabbit on #607: a short
+ // trainer panicked where `# Errors` promises `Invalid`).
+ if let Some(t) = trainer
+ && t.len() < TRAINER_LEN
+ {
+ return Err(MapperError::Invalid(format!(
+ "FFE trainer is {} bytes, expected {TRAINER_LEN}",
+ t.len()
+ )));
+ }
+ let mut prg = vec![0u8; PRG_DRAM].into_boxed_slice();
+ prg[..prg_rom.len()].copy_from_slice(prg_rom);
+ let mut chr = vec![0u8; CHR_RAM].into_boxed_slice();
+ chr[..chr_rom.len()].copy_from_slice(chr_rom);
+ let mut m = Self {
+ board,
+ prg,
+ chr,
+ wram: vec![0u8; WRAM].into_boxed_slice(),
+ scratch: vec![0u8; SCRATCH].into_boxed_slice(),
+ latch_mode: 0,
+ prg_protected: true,
+ mirroring_type: 2,
+ latch: 0,
+ mode_2m4m_enabled: false,
+ mode_2m: false,
+ chr_cc: 0,
+ reg_2m: [0; 4],
+ reg_4m: [0; 4],
+ smc_mode: 0,
+ chr_1k: [0, 1, 2, 3, 4, 5, 6, 7, 0, 0, 0, 0],
+ mmc4_latch: [false; 2],
+ irq_counter: 0,
+ irq_enabled: false,
+ irq_pending: false,
+ fds_irq_enabled: false,
+ fds_irq_thirds: 0,
+ fds_irq_pending: false,
+ boot_vector: None,
+ stub_live: false,
+ last_a12: false,
+ };
+ let h_bit = if vertical { 0x00 } else { 0x10 };
+ match board {
+ FfeBoard::MagicCard(sub) => {
+ m.cpu_write(0x4500, 0x42);
+ m.cpu_write(0x42FF, ((sub & 0x07) << 5) | h_bit);
+ m.cpu_write(0x43FF, 0x00);
+ if let Some(t) = trainer {
+ m.wram[0x1000..0x1000 + TRAINER_LEN].copy_from_slice(&t[..TRAINER_LEN]);
+ m.boot_vector = Some(STUB_ADDR);
+ m.stub_live = true;
+ }
+ }
+ FfeBoard::SuperMagicCard(sub) => {
+ m.cpu_write(0x4500, 0x47);
+ m.cpu_write(0x42FF, 0x20 | h_bit);
+ m.cpu_write(0x43FC, 0x00);
+ let n = (prg_rom.len() / BANK_8K) as u8;
+ for i in 0..4u8 {
+ m.cpu_write(0x4504 + u16::from(i), n.wrapping_sub(4 - i));
+ }
+ if let Some(t) = trainer {
+ let at: u16 = match sub {
+ 1 => 0x5D00,
+ 2 => 0x5E00,
+ 3 => 0x5F00,
+ _ => 0x7000,
+ };
+ for (i, &b) in t[..TRAINER_LEN].iter().enumerate() {
+ m.store_low(at + i as u16, b);
+ }
+ m.boot_vector = Some(at);
+ }
+ }
+ }
+ Ok(m)
+ }
+
+ /// Store into scratch RAM or WRAM (trainer loading, which crosses
+ /// `$5FFF -> $6000` for submapper 3).
+ fn store_low(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x5000..=0x5FFF => self.scratch[usize::from(addr - 0x5000)] = value,
+ 0x6000..=0x7FFF => {
+ let off = self.wram_offset(addr);
+ self.wram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn wram_offset(&self, addr: u16) -> usize {
+ usize::from((self.smc_mode >> 4) & 0x03) * BANK_8K + usize::from(addr & 0x1FFF)
+ }
+
+ fn chr_1k_mode(&self) -> bool {
+ self.smc_mode & 0x01 != 0
+ }
+
+ fn ciram_nametables(&self) -> bool {
+ self.smc_mode & 0x02 != 0
+ }
+
+ fn mmc4_mode(&self) -> bool {
+ self.chr_1k_mode() && self.smc_mode & 0x04 == 0
+ }
+
+ fn irq_from_a12(&self) -> bool {
+ self.smc_mode & 0x08 != 0
+ }
+
+ /// The DRAM offset for CPU `addr` (`$8000-$FFFF`) in Play mode.
+ fn prg_offset(&self, addr: u16) -> usize {
+ let slot = usize::from((addr >> 13) & 0x03);
+ let in8 = usize::from(addr & 0x1FFF);
+ if self.mode_2m4m_enabled {
+ let bank = if self.mode_2m {
+ usize::from(self.reg_2m[slot])
+ } else {
+ usize::from(self.reg_4m[slot] & 0x3F)
+ };
+ return (bank * BANK_8K + in8) % PRG_DRAM;
+ }
+ let upper = addr >= 0xC000;
+ let in16 = usize::from(addr & 0x3FFF);
+ let l = usize::from(self.latch);
+ let bank16 = |b: usize| (b * BANK_16K + in16) % PRG_DRAM;
+ let bank32 = |b: usize| (b * BANK_32K + usize::from(addr & 0x7FFF)) % PRG_DRAM;
+ match self.latch_mode {
+ 0 => bank16(if upper { 7 } else { l & 0x07 }),
+ 1 => bank16(if upper { 7 } else { (l >> 2) & 0x0F }),
+ 2 => bank16(if upper { 15 } else { l & 0x0F }),
+ 3 => bank16(if upper { l & 0x0F } else { 15 }),
+ 4 => bank32((l >> 4) & 0x03),
+ _ => bank32(3),
+ }
+ }
+
+ /// The 8 KiB CHR bank in 8 KiB mode.
+ fn chr_8k_bank(&self) -> usize {
+ if self.mode_2m4m_enabled {
+ return usize::from(self.chr_cc & 0x03);
+ }
+ let l = usize::from(self.latch);
+ match self.latch_mode {
+ 1 | 4 | 5 => l & 0x03,
+ 3 => (l >> 4) & 0x03,
+ 6 => l & 0x01,
+ _ => 0,
+ }
+ }
+
+ /// CHR-RAM is write-protected in latch modes 4-7, whatever banking mode
+ /// is active ("except CHR memory protection, which is still decided by
+ /// the 1M Mode Register").
+ fn chr_writable(&self) -> bool {
+ self.latch_mode < 4
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let addr = usize::from(addr & 0x1FFF);
+ if self.mmc4_mode() {
+ let half = addr >> 12;
+ let reg = half * 4 + usize::from(self.mmc4_latch[half]) * 2;
+ let bank4 = usize::from(self.chr_1k[reg] >> 2);
+ (bank4 * 0x1000 + (addr & 0x0FFF)) % CHR_RAM
+ } else if self.chr_1k_mode() {
+ usize::from(self.chr_1k[addr >> 10]) * 0x400 + (addr & 0x03FF)
+ } else {
+ (self.chr_8k_bank() * BANK_8K + addr) % CHR_RAM
+ }
+ }
+
+ /// MMC4 mode: a pattern read of `$xFD8-$xFDF` or `$xFE8-$xFEF` switches
+ /// that half's 4 KiB bank for the reads that follow.
+ fn mmc4_watch(&mut self, addr: u16) {
+ if !self.mmc4_mode() {
+ return;
+ }
+ let half = usize::from((addr >> 12) & 1);
+ match addr & 0x0FF8 {
+ 0x0FD8 => self.mmc4_latch[half] = false,
+ 0x0FE8 => self.mmc4_latch[half] = true,
+ _ => {}
+ }
+ }
+
+ fn mirroring(&self) -> Mirroring {
+ match self.mirroring_type & 0x03 {
+ 0 => Mirroring::SingleScreenA,
+ 1 => Mirroring::SingleScreenB,
+ 2 => Mirroring::Vertical,
+ _ => Mirroring::Horizontal,
+ }
+ }
+
+ fn nt_chr_offset(&self, addr: u16) -> usize {
+ let table = usize::from((addr >> 10) & 0x03);
+ usize::from(self.chr_1k[8 + table]) * 0x400 + usize::from(addr & 0x03FF)
+ }
+
+ fn state_register_1(&self) -> u8 {
+ // W = 0 (no parallel-port byte), B = 1 (button not pressed).
+ 0x40 | (self.latch_mode << 3)
+ | ((self.mirroring_type & 0x01) << 2)
+ | (u8::from(self.prg_protected) << 1)
+ | (self.mirroring_type >> 1)
+ }
+
+ fn state_register_2(&self) -> u8 {
+ (self.latch << 2) | (u8::from(!self.mode_2m) << 1) | u8::from(!self.mode_2m4m_enabled)
+ }
+
+ fn write_register(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x42FC..=0x42FF => {
+ self.prg_protected = addr & 0x02 != 0;
+ self.mirroring_type = (((addr & 0x01) as u8) << 1) | ((value >> 4) & 0x01);
+ self.latch_mode = value >> 5;
+ }
+ 0x43FC..=0x43FF => {
+ self.mode_2m4m_enabled = addr & 0x01 == 0;
+ self.mode_2m = addr & 0x02 != 0;
+ self.chr_cc = value & 0x03;
+ }
+ 0x4500 => self.smc_mode = value,
+ 0x4501 => {
+ self.irq_enabled = false;
+ self.irq_pending = false;
+ }
+ 0x4502 => {
+ self.irq_counter = (self.irq_counter & 0xFF00) | u16::from(value);
+ self.irq_pending = false;
+ }
+ 0x4503 => {
+ self.irq_counter = (self.irq_counter & 0x00FF) | (u16::from(value) << 8);
+ self.irq_pending = false;
+ self.irq_enabled = true;
+ }
+ 0x4504..=0x4507 => {
+ let i = usize::from(addr - 0x4504);
+ self.reg_4m[i] = value & 0x3F;
+ // "Mirrors of the 2M registers" whenever 4M mode is not the
+ // active one.
+ if !self.mode_2m4m_enabled || self.mode_2m {
+ self.reg_2m[i] = value & 0x3F;
+ }
+ }
+ 0x4510..=0x451B => self.chr_1k[usize::from(addr - 0x4510)] = value,
+ _ => {}
+ }
+ }
+
+ fn clock_irq(&mut self) {
+ if self.irq_enabled {
+ let (next, wrapped) = self.irq_counter.overflowing_add(1);
+ self.irq_counter = next;
+ if wrapped {
+ self.irq_pending = true;
+ }
+ }
+ }
+}
+
+impl Mapper for SuperMagicCard {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn sram(&self) -> &[u8] {
+ &self.wram
+ }
+
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.wram
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ !matches!(addr, 0x4500 | 0x4501 | 0x5000..=0xFFFF)
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if let Some(v) = self.boot_vector
+ && (addr == 0xFFFC || addr == 0xFFFD)
+ {
+ if addr == 0xFFFD {
+ self.boot_vector = None;
+ }
+ return v.to_le_bytes()[usize::from(addr - 0xFFFC)];
+ }
+ if self.stub_live && (STUB_ADDR..STUB_ADDR + 6).contains(&addr) {
+ if addr == STUB_ADDR + 5 {
+ self.stub_live = false;
+ }
+ return STUB[usize::from(addr - STUB_ADDR)];
+ }
+ match addr {
+ 0x4500 => self.state_register_1(),
+ 0x4501 => self.state_register_2(),
+ 0x5000..=0x5FFF => self.scratch[usize::from(addr - 0x5000)],
+ 0x6000..=0x7FFF => self.wram[self.wram_offset(addr)],
+ 0x8000..=0xFFFF => self.prg[self.prg_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ /// The read's only side effects are the trainer boot's two one-shots
+ /// (`boot_vector` retired by the `$FFFD` read, `stub_live` by the stub's
+ /// last byte); every other path above is a pure lookup. So the peek is
+ /// the read with those two put back, which returns exactly the byte the
+ /// CPU would read and cannot drift from `cpu_read`.
+ fn cpu_peek(&mut self, addr: u16) -> u8 {
+ let (vector, stub) = (self.boot_vector, self.stub_live);
+ let v = self.cpu_read(addr);
+ (self.boot_vector, self.stub_live) = (vector, stub);
+ v
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ // The FDS RAM adapter under the card.
+ 0x4024 => self.fds_irq_pending = false,
+ 0x4025 => {
+ self.fds_irq_enabled = value & 0x80 != 0;
+ self.fds_irq_thirds = 0;
+ if !self.fds_irq_enabled {
+ self.fds_irq_pending = false;
+ }
+ }
+ 0x4020..=0x4FFF => self.write_register(addr, value),
+ 0x5000..=0x7FFF => self.store_low(addr, value),
+ 0x8000..=0xFFFF => {
+ if self.prg_protected {
+ self.latch = value;
+ let slot = usize::from((addr >> 13) & 0x03);
+ self.reg_2m[slot] = value >> 2;
+ if self.mode_2m4m_enabled {
+ self.chr_cc = value & 0x03;
+ }
+ } else {
+ let off = self.prg_offset(addr);
+ self.prg[off] = value;
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn reset(&mut self) {
+ // A soft reset runs the ordinary vector; the trainer call is for a
+ // hard reset only.
+ self.boot_vector = None;
+ self.stub_live = false;
+ }
+
+ fn chr_reads_are_pure(&self) -> bool {
+ !self.mmc4_mode()
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let v = self.chr[self.chr_offset(addr)];
+ self.mmc4_watch(addr);
+ v
+ }
+ 0x2000..=0x3EFF if !self.ciram_nametables() => self.chr[self.nt_chr_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF if !self.ciram_nametables() => self.chr[self.nt_chr_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF if self.chr_writable() => {
+ let off = self.chr_offset(addr);
+ self.chr[off] = value;
+ }
+ 0x2000..=0x3EFF if !self.ciram_nametables() => {
+ let off = self.nt_chr_offset(addr);
+ self.chr[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn nametable_fetch(&mut self, addr: u16) -> Option {
+ (!self.ciram_nametables()).then(|| self.chr[self.nt_chr_offset(addr)])
+ }
+
+ fn nametable_write(&mut self, addr: u16, value: u8) -> bool {
+ if self.ciram_nametables() {
+ return false;
+ }
+ let off = self.nt_chr_offset(addr);
+ self.chr[off] = value;
+ true
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ if self.ciram_nametables() {
+ self.mirroring()
+ } else {
+ Mirroring::MapperControlled
+ }
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ if level && !self.last_a12 && self.irq_from_a12() {
+ self.clock_irq();
+ }
+ self.last_a12 = level;
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ if !self.irq_from_a12() {
+ self.clock_irq();
+ }
+ if self.fds_irq_enabled {
+ self.fds_irq_thirds += 3;
+ if self.fds_irq_thirds >= FDS_IRQ_THIRDS {
+ self.fds_irq_thirds -= FDS_IRQ_THIRDS;
+ self.fds_irq_pending = true;
+ }
+ }
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.irq_pending || self.fds_irq_pending
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let (id, name) = match self.board {
+ FfeBoard::MagicCard(4) => (8, "FFE Magic Card, GNROM (8)"),
+ FfeBoard::MagicCard(_) => (6, "FFE Magic Card (6)"),
+ FfeBoard::SuperMagicCard(_) => (17, "FFE Super Magic Card (17)"),
+ };
+ let mut info = MapperDebugInfo {
+ mapper_id: id,
+ name: name.into(),
+ mirroring: mirroring_name(self.current_mirroring()),
+ ..Default::default()
+ };
+ let mode = if !self.mode_2m4m_enabled {
+ format!("latch {}", self.latch_mode)
+ } else if self.mode_2m {
+ "2M".into()
+ } else {
+ "4M".into()
+ };
+ info.prg_banks.push(("mode".into(), mode));
+ for slot in 0..4u16 {
+ let base = 0x8000 + slot * 0x2000;
+ info.prg_banks.push((
+ format!("${base:04X}"),
+ format!("{:#07x}", self.prg_offset(base)),
+ ));
+ }
+ info.chr_banks.push((
+ "CHR".into(),
+ if self.mmc4_mode() {
+ "MMC4".into()
+ } else if self.chr_1k_mode() {
+ format!("1K {:?}", &self.chr_1k[..8])
+ } else {
+ format!("8K bank {}", self.chr_8k_bank())
+ },
+ ));
+ info.irq_state
+ .push(("counter".into(), format!("{:#06x}", self.irq_counter)));
+ info.irq_state
+ .push(("enabled".into(), format!("{}", self.irq_enabled)));
+ info.irq_state
+ .push(("fds".into(), format!("{}", self.fds_irq_enabled)));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(
+ 64 + self.prg.len() + self.chr.len() + self.wram.len() + self.scratch.len(),
+ );
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.latch_mode);
+ out.push(u8::from(self.prg_protected));
+ out.push(self.mirroring_type);
+ out.push(self.latch);
+ out.push(u8::from(self.mode_2m4m_enabled));
+ out.push(u8::from(self.mode_2m));
+ out.push(self.chr_cc);
+ out.extend_from_slice(&self.reg_2m);
+ out.extend_from_slice(&self.reg_4m);
+ out.push(self.smc_mode);
+ out.extend_from_slice(&self.chr_1k);
+ out.push(u8::from(self.mmc4_latch[0]));
+ out.push(u8::from(self.mmc4_latch[1]));
+ out.extend_from_slice(&self.irq_counter.to_le_bytes());
+ out.push(u8::from(self.irq_enabled));
+ out.push(u8::from(self.irq_pending));
+ out.push(u8::from(self.fds_irq_enabled));
+ out.extend_from_slice(&self.fds_irq_thirds.to_le_bytes());
+ out.push(u8::from(self.fds_irq_pending));
+ out.extend_from_slice(&self.boot_vector.unwrap_or(0).to_le_bytes());
+ out.push(u8::from(self.boot_vector.is_some()));
+ out.push(u8::from(self.stub_live));
+ out.push(u8::from(self.last_a12));
+ out.extend_from_slice(&self.prg);
+ out.extend_from_slice(&self.chr);
+ out.extend_from_slice(&self.wram);
+ out.extend_from_slice(&self.scratch);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ const HEAD: usize = 1 + 7 + 4 + 4 + 1 + 12 + 2 + 2 + 2 + 1 + 2 + 1 + 2 + 1 + 1 + 1;
+ let expected = HEAD + PRG_DRAM + CHR_RAM + WRAM + SCRATCH;
+ if data.len() != expected {
+ return Err(MapperError::WrongLength {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ // The FDS IRQ phase only ever holds 0..FDS_IRQ_THIRDS (the tick wraps
+ // it), and `+= 3` on a value near u16::MAX would panic in a debug build
+ // and wrap in a release one, so the two builds diverged on a crafted
+ // state (CodeRabbit on #607). Checked before anything is restored.
+ let thirds = u16::from_le_bytes([data[36], data[37]]);
+ if thirds >= FDS_IRQ_THIRDS {
+ return Err(MapperError::Invalid(format!(
+ "FFE FDS IRQ phase {thirds} out of range (below {FDS_IRQ_THIRDS})"
+ )));
+ }
+ let mut c = 1;
+ let mut byte = || {
+ let v = data[c];
+ c += 1;
+ v
+ };
+ self.latch_mode = byte() & 0x07;
+ self.prg_protected = byte() != 0;
+ self.mirroring_type = byte() & 0x03;
+ self.latch = byte();
+ self.mode_2m4m_enabled = byte() != 0;
+ self.mode_2m = byte() != 0;
+ self.chr_cc = byte() & 0x03;
+ for i in 0..4 {
+ self.reg_2m[i] = byte();
+ }
+ for i in 0..4 {
+ self.reg_4m[i] = byte();
+ }
+ self.smc_mode = byte();
+ for i in 0..12 {
+ self.chr_1k[i] = byte();
+ }
+ self.mmc4_latch = [byte() != 0, byte() != 0];
+ self.irq_counter = u16::from_le_bytes([byte(), byte()]);
+ self.irq_enabled = byte() != 0;
+ self.irq_pending = byte() != 0;
+ self.fds_irq_enabled = byte() != 0;
+ self.fds_irq_thirds = u16::from_le_bytes([byte(), byte()]);
+ self.fds_irq_pending = byte() != 0;
+ let vector = u16::from_le_bytes([byte(), byte()]);
+ self.boot_vector = (byte() != 0).then_some(vector);
+ self.stub_live = byte() != 0;
+ self.last_a12 = byte() != 0;
+ let mut rest = &data[c..];
+ for buf in [
+ &mut self.prg[..],
+ &mut self.chr[..],
+ &mut self.wram[..],
+ &mut self.scratch[..],
+ ] {
+ let (now, later) = rest.split_at(buf.len());
+ buf.copy_from_slice(now);
+ rest = later;
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parse;
+
+ #[test]
+ fn a_short_trainer_is_an_error_not_a_panic() {
+ let prg = vec![0u8; BANK_8K * 4];
+ for board in [FfeBoard::MagicCard(1), FfeBoard::SuperMagicCard(0)] {
+ let r = SuperMagicCard::new(board, &prg, &[], Some(&[0u8; 100]), false);
+ assert!(matches!(r, Err(MapperError::Invalid(_))), "{board:?}");
+ }
+ }
+
+ #[test]
+ fn a_state_with_an_impossible_fds_irq_phase_is_refused() {
+ let prg = vec![0u8; BANK_8K * 4];
+ let m = SuperMagicCard::new(FfeBoard::SuperMagicCard(0), &prg, &[], None, false).unwrap();
+ let good = m.save_state();
+ let mut bad = good.clone();
+ bad[36..38].copy_from_slice(&0xFFFEu16.to_le_bytes());
+ let mut other =
+ SuperMagicCard::new(FfeBoard::SuperMagicCard(0), &prg, &[], None, false).unwrap();
+ other.cpu_write(0x4501, 0x01);
+ let before = other.save_state();
+ assert!(matches!(
+ other.load_state(&bad),
+ Err(MapperError::Invalid(_))
+ ));
+ assert_eq!(other.save_state(), before, "nothing restored");
+ other.load_state(&good).unwrap();
+ }
+
+ /// An image through `crate::parse`: `prg_16k` x 16 KiB PRG whose every
+ /// 16 KiB page opens with its index, `chr_8k` x 8 KiB CHR likewise, NES
+ /// 2.0 with `submapper` when given, and an optional 512-byte trainer.
+ fn image(
+ mapper: u8,
+ prg_16k: usize,
+ chr_8k: usize,
+ submapper: Option,
+ trainer: bool,
+ ) -> Vec {
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = prg_16k as u8;
+ v[5] = chr_8k as u8;
+ v[6] = ((mapper & 0x0F) << 4) | 0x01 | if trainer { 0x04 } else { 0 };
+ v[7] = mapper & 0xF0;
+ if let Some(s) = submapper {
+ v[7] |= 0x08;
+ v[8] = s << 4;
+ }
+ if trainer {
+ let mut t = vec![0xEAu8; TRAINER_LEN];
+ t[0] = 0x4C; // a recognisable first byte
+ v.extend_from_slice(&t);
+ }
+ for p in 0..prg_16k {
+ let mut page = vec![0u8; BANK_16K];
+ page[0] = p as u8;
+ page[0x2000] = 0x80 | p as u8; // second 8 KiB of the page
+ page[0x3FFC] = 0x34;
+ page[0x3FFD] = 0x12;
+ v.extend_from_slice(&page);
+ }
+ for b in 0..chr_8k {
+ let mut bank = vec![0u8; BANK_8K];
+ bank[0] = b as u8;
+ bank[0x400] = 0x40 | b as u8;
+ v.extend_from_slice(&bank);
+ }
+ v
+ }
+
+ fn board(
+ mapper: u8,
+ prg_16k: usize,
+ chr_8k: usize,
+ submapper: Option,
+ trainer: bool,
+ ) -> Box {
+ parse(&image(mapper, prg_16k, chr_8k, submapper, trainer))
+ .unwrap_or_else(|e| panic!("mapper {mapper} must parse: {e:?}"))
+ .1
+ }
+
+ #[test]
+ fn m6_ines_defaults_to_un1rom_chrsw_with_page_7_fixed() {
+ let mut m = board(6, 16, 0, None, false);
+ assert_eq!(m.cpu_read(0xC000), 7, "latch mode 1: page 7 fixed");
+ m.cpu_write(0x8000, 0b0010_1110); // PRG 11 (D5-D2), CHR 2 (D1-D0)
+ assert_eq!(m.cpu_read(0x8000), 11);
+ assert_eq!(m.cpu_read(0xC000), 7);
+ m.ppu_write(0x0005, 0x99);
+ assert_eq!(m.ppu_read(0x0005), 0x99, "CHR-RAM writable in mode 1");
+ m.cpu_write(0x8000, 0b0010_1100); // CHR 0
+ assert_ne!(m.ppu_read(0x0005), 0x99, "a different 8 KiB bank");
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical, "header V");
+ }
+
+ #[test]
+ fn m6_submappers_select_the_latch_modes() {
+ // 0 UNROM, 2 UOROM, 3 reverse UOROM, 4 GNROM, 5-7 fixed 32 KiB #3.
+ let mut m = board(6, 32, 0, Some(0), false);
+ m.cpu_write(0x8000, 0xFD);
+ assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (5, 7));
+ let mut m = board(6, 32, 0, Some(2), false);
+ m.cpu_write(0x8000, 0x0C);
+ assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (12, 15));
+ let mut m = board(6, 32, 0, Some(3), false);
+ m.cpu_write(0x8000, 0x1C); // CC = 1, PPPP = 12
+ assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (15, 12));
+ let mut m = board(6, 32, 4, Some(4), false);
+ m.cpu_write(0x8000, 0x22); // PP = 2, CC = 2
+ assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (4, 5));
+ assert_eq!(m.ppu_read(0x0000), 2);
+ m.ppu_write(0x0000, 0x77);
+ assert_eq!(m.ppu_read(0x0000), 2, "GNROM mode: CHR write-protected");
+ for sub in 5..=7 {
+ let mut m = board(6, 32, 4, Some(sub), false);
+ m.cpu_write(0x8000, 0x03);
+ assert_eq!(
+ (m.cpu_read(0x8000), m.cpu_read(0xC000)),
+ (6, 7),
+ "sub {sub}"
+ );
+ let want = match sub {
+ 5 => 3,
+ 6 => 1,
+ _ => 0,
+ };
+ assert_eq!(m.ppu_read(0x0000), want, "sub {sub} CHR");
+ }
+ }
+
+ #[test]
+ fn m8_is_m6_submapper_4() {
+ let mut m = board(8, 8, 4, None, false);
+ m.cpu_write(0x8000, 0x11); // PP = 1, CC = 1
+ assert_eq!((m.cpu_read(0x8000), m.cpu_read(0xC000)), (2, 3));
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+
+ #[test]
+ fn mirroring_types_and_write_enable() {
+ let mut m = board(6, 16, 0, None, false);
+ m.cpu_write(0x42FE, 0x20); // A1 = 1, A0 = 0, D4 = 0: type 0
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
+ m.cpu_write(0x42FE, 0x30); // type 1
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
+ m.cpu_write(0x42FF, 0x30); // type 3
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ // A1 = 0: the DRAM is writable and the latch is off.
+ m.cpu_write(0x42FD, 0x20);
+ m.cpu_write(0x8001, 0xAB);
+ assert_eq!(m.cpu_read(0x8001), 0xAB, "written into PRG DRAM");
+ m.cpu_write(0x42FF, 0x20);
+ assert_eq!(m.cpu_read(0x8001), 0xAB, "latch untouched by the write");
+ assert_eq!(m.cpu_read(0x4500) & 0x3F, (1 << 3) | 0x02 | 0x01);
+ }
+
+ #[test]
+ fn m17_starts_in_4m_mode_with_the_last_banks_and_1k_chr() {
+ let mut m = board(17, 16, 16, None, false);
+ // 32 8 KiB banks: $4504-$4507 = 28..31.
+ assert_eq!(
+ m.cpu_read(0x8000),
+ 14,
+ "bank 28 = 16 KiB page 14, first half"
+ );
+ assert_eq!(m.cpu_read(0xA000), 0x8E, "bank 29");
+ assert_eq!(m.cpu_read(0xE000), 0x8F, "bank 31");
+ m.cpu_write(0x4505, 3);
+ assert_eq!(m.cpu_read(0xA000), 0x81);
+ // $8000 writes do not move the 4M banks.
+ m.cpu_write(0x8000, 0xFC);
+ assert_eq!(m.cpu_read(0x8000), 14);
+ // 1 KiB CHR: $4511 picks the 1 KiB at $0400.
+ m.cpu_write(0x4511, 9); // 8 KiB bank 1, second 1 KiB
+ assert_eq!(m.ppu_read(0x0400), 0x40 | 1);
+ // 2M mode: the four $8000-$FFFF registers, D7-D2.
+ m.cpu_write(0x43FE, 0x00); // A0 = 0 enable, A1 = 1: 2M
+ m.cpu_write(0xC000, 5 << 2);
+ assert_eq!(
+ m.cpu_read(0xC000),
+ 0x82,
+ "2M: 8 KiB bank 5 = page 2's upper half"
+ );
+ }
+
+ #[test]
+ fn chr_nametables_when_n_is_clear() {
+ let mut m = board(17, 16, 16, None, false);
+ assert_eq!(m.nametable_fetch(0x2000), None, "N = 1: CIRAM");
+ m.cpu_write(0x4500, 0x45); // N = 0
+ m.cpu_write(0x4518, 1); // $2000 <- CHR 1 KiB page 1
+ m.cpu_write(0x451B, 8); // $2C00 <- page 8
+ assert_eq!(m.nametable_fetch(0x2000), Some(0x40));
+ assert_eq!(m.nametable_fetch(0x2C00), Some(1));
+ assert!(m.nametable_write(0x2C01, 0x5A));
+ assert_eq!(m.nametable_fetch(0x2C01), Some(0x5A));
+ assert_eq!(m.current_mirroring(), Mirroring::MapperControlled);
+ }
+
+ #[test]
+ fn mmc4_mode_switches_4k_banks_after_the_latch_tiles() {
+ let mut m = board(17, 16, 16, None, false);
+ m.cpu_write(0x4500, 0x43); // C = 1, m = 0: MMC4 mode
+ m.cpu_write(0x4510, 2 << 2); // $FD bank for $0000: 4 KiB bank 2
+ m.cpu_write(0x4512, 4 << 2); // $FE bank: 4 KiB bank 4 (= 8 KiB bank 2)
+ assert_eq!(m.ppu_read(0x0000), 1, "4 KiB bank 2 = 8 KiB bank 1");
+ let _ = m.ppu_read(0x0FE8);
+ assert_eq!(m.ppu_read(0x0000), 2, "after $0FE8: the $FE bank");
+ assert!(!m.chr_reads_are_pure());
+ let _ = m.ppu_read(0x0FD8);
+ assert_eq!(m.ppu_read(0x0000), 1);
+ }
+
+ #[test]
+ fn irq_counter_wraps_and_fds_timer_fires() {
+ let mut m = board(6, 16, 0, None, false);
+ m.cpu_write(0x4502, 0xFE);
+ m.cpu_write(0x4503, 0xFF); // $FFFE, enabled, M2 source
+ m.notify_cpu_cycle();
+ assert!(!m.irq_pending());
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending(), "$FFFF -> $0000");
+ m.cpu_write(0x4501, 0);
+ assert!(!m.irq_pending());
+ // PA12 source.
+ m.cpu_write(0x4500, 0x4A);
+ m.cpu_write(0x4502, 0xFF);
+ m.cpu_write(0x4503, 0xFF);
+ m.notify_cpu_cycle();
+ assert!(!m.irq_pending(), "M2 does not clock it");
+ m.notify_a12(true);
+ assert!(m.irq_pending(), "one PA12 rise");
+ m.cpu_write(0x4501, 0);
+ // FDS adapter: 149 1/3 CPU cycles.
+ m.cpu_write(0x4025, 0x80);
+ for _ in 0..149 {
+ m.notify_cpu_cycle();
+ }
+ assert!(!m.irq_pending());
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending(), "150th cycle");
+ m.cpu_write(0x4024, 0);
+ assert!(!m.irq_pending(), "$4024 acknowledges");
+ }
+
+ #[test]
+ fn trainers_run_on_a_hard_reset_only() {
+ // Mapper 6: JSR $7003 then JMP ($FFFC).
+ let mut m = board(6, 16, 0, None, true);
+ assert_eq!(m.cpu_read(0x7000), 0x4C, "trainer at $7000");
+ assert_eq!(m.cpu_read(0xFFFC), 0x00);
+ assert_eq!(m.cpu_read(0xFFFD), 0x72, "power-on vector -> $7200 stub");
+ let stub: Vec = (0..6).map(|i| m.cpu_read(0x7200 + i)).collect();
+ assert_eq!(stub, STUB);
+ assert_eq!(m.cpu_read(0x7200), 0, "the stub is gone");
+ assert_eq!(m.cpu_read(0xFFFD), 0x12, "the game's own vector");
+ // Mapper 17 submapper 2: JMP to the trainer at $5E00.
+ let mut m = board(17, 16, 16, Some(2), true);
+ assert_eq!(m.cpu_read(0x5E00), 0x4C);
+ assert_eq!((m.cpu_read(0xFFFC), m.cpu_read(0xFFFD)), (0x00, 0x5E));
+ // A soft reset uses the ordinary vector.
+ let mut m = board(17, 16, 16, None, true);
+ m.reset();
+ assert_ne!(m.cpu_read(0xFFFD), 0x70);
+ }
+
+ #[test]
+ fn a_debugger_peek_leaves_the_trainer_boot_armed() {
+ // The debugger, the disassembler and Lua read through `cpu_peek`.
+ // Peeking every byte the trainer boot consumes must return what the
+ // CPU would read and leave the boot exactly as armed, so the CPU's
+ // own reads afterwards still take the stub (agy on #604).
+ let mut m = board(6, 16, 0, None, true);
+ let before = m.save_state();
+ assert_eq!((m.cpu_peek(0xFFFC), m.cpu_peek(0xFFFD)), (0x00, 0x72));
+ let stub: Vec = (0..6).map(|i| m.cpu_peek(0x7200 + i)).collect();
+ assert_eq!(stub, STUB, "a peek shows the stub");
+ assert_eq!(m.save_state(), before, "a peek changed the board");
+ assert_eq!(m.cpu_read(0xFFFD), 0x72, "the CPU still boots the stub");
+ let stub: Vec = (0..6).map(|i| m.cpu_read(0x7200 + i)).collect();
+ assert_eq!(stub, STUB);
+ // Once the CPU has retired both, a peek sees the game's bytes.
+ assert_eq!(m.cpu_peek(0xFFFD), 0x12);
+ assert_eq!(m.cpu_peek(0x7200), 0);
+ }
+
+ #[test]
+ fn state_round_trips() {
+ let mut a = board(17, 16, 16, None, true);
+ a.cpu_write(0x4506, 7);
+ a.cpu_write(0x6000, 0x31);
+ a.cpu_write(0x5123, 0x32);
+ a.ppu_write(0x0100, 0x33);
+ let blob = a.save_state();
+ let mut b = board(17, 16, 16, None, true);
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.cpu_read(0xC000), a.cpu_read(0xC000));
+ assert_eq!(b.cpu_read(0x6000), 0x31);
+ assert_eq!(b.cpu_read(0x5123), 0x32);
+ assert_eq!(b.ppu_read(0x0100), 0x33);
+ assert!(b.load_state(&blob[1..]).is_err());
+ }
+}
diff --git a/crates/rustynes-mappers/src/homebrew_boards.rs b/crates/rustynes-mappers/src/homebrew_boards.rs
index 9062f6a14..2cbffe8dc 100644
--- a/crates/rustynes-mappers/src/homebrew_boards.rs
+++ b/crates/rustynes-mappers/src/homebrew_boards.rs
@@ -176,6 +176,15 @@ impl Mapper for Inl31 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -356,6 +365,15 @@ impl Mapper for MagicFloor218 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.ciram[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.ciram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -504,6 +522,15 @@ impl Mapper for Cufrom29 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -595,6 +622,7 @@ pub struct Gtrom111 {
prg_bank: u8,
chr_bank: u8,
nt_bank: u8,
+ dirty: bool,
}
/// GTROM save-state layout version. v2 (v2.9.6) grew the nametable pages to
@@ -625,6 +653,7 @@ impl Gtrom111 {
prg_bank: 0,
chr_bank: 0,
nt_bank: 0,
+ dirty: false,
})
}
@@ -669,13 +698,28 @@ impl Mapper for Gtrom111 {
}
fn save_data_mut(&mut self) -> &mut [u8] {
+ // Every path that can change the save data marks it dirty, not just a
+ // flash program write: the battery writer skips its comparison while
+ // this board reports clean (`BatteryPolicy::differs`), so a missed
+ // path is a lost save. A spurious `true` only costs one comparison.
+ self.dirty = true;
&mut self.flash
}
fn clear_save_data(&mut self) {
+ // Marks the save data dirty; see `save_data_mut`.
+ self.dirty = true;
self.flash.copy_from_slice(&self.original);
}
+ fn is_save_data_dirty(&self) -> Option {
+ Some(self.dirty)
+ }
+
+ fn clear_save_data_dirty(&mut self) {
+ self.dirty = false;
+ }
+
/// The register is write-only and nothing else lives below `$8000`.
fn cpu_read_unmapped(&self, addr: u16) -> bool {
addr < 0x8000
@@ -701,7 +745,9 @@ impl Mapper for Gtrom111 {
self.update_register(value);
} else if addr >= 0x8000 {
let a = self.chip_addr(addr);
- self.chip.write(&mut self.flash, a, value);
+ if self.chip.write(&mut self.flash, a, value) {
+ self.dirty = true;
+ }
}
}
@@ -714,6 +760,15 @@ impl Mapper for Gtrom111 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.nt_ram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -786,6 +841,8 @@ impl Mapper for Gtrom111 {
}
fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ // Marks the save data dirty; see `save_data_mut`.
+ self.dirty = true;
let fixed = 7 + self.chr_ram.len() + self.nt_ram.len();
if data.len() < fixed {
return Err(MapperError::WrongLength {
@@ -1021,6 +1078,15 @@ impl Mapper for Action53M28 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1193,6 +1259,7 @@ pub struct Unrom512M30 {
/// True when the banking latch responds only to $C000-$FFFF and
/// $8000-$BFFF is the flash window (submapper 0 w/ battery, or 1/3/4).
flash_window: bool,
+ dirty: bool,
}
impl Unrom512M30 {
@@ -1273,6 +1340,7 @@ impl Unrom512M30 {
nametable,
bus_conflicts,
flash_window,
+ dirty: false,
})
}
@@ -1357,6 +1425,11 @@ impl Mapper for Unrom512M30 {
}
fn save_data_mut(&mut self) -> &mut [u8] {
+ // Every path that can change the save data marks it dirty, not just a
+ // flash program write: the battery writer skips its comparison while
+ // this board reports clean (`BatteryPolicy::differs`), so a missed
+ // path is a lost save. A spurious `true` only costs one comparison.
+ self.dirty = true;
if self.flashable() {
&mut self.prg_rom
} else {
@@ -1365,11 +1438,25 @@ impl Mapper for Unrom512M30 {
}
fn clear_save_data(&mut self) {
+ // Marks the save data dirty; see `save_data_mut`.
+ self.dirty = true;
if self.flashable() {
self.prg_rom.copy_from_slice(&self.original);
}
}
+ fn is_save_data_dirty(&self) -> Option {
+ if self.flashable() {
+ Some(self.dirty)
+ } else {
+ None
+ }
+ }
+
+ fn clear_save_data_dirty(&mut self) {
+ self.dirty = false;
+ }
+
/// Nothing drives `$4020-$7FFF` on any wiring.
fn cpu_read_unmapped(&self, addr: u16) -> bool {
addr < 0x8000
@@ -1404,7 +1491,9 @@ impl Mapper for Unrom512M30 {
self.write_latch(addr, value);
} else if self.flashable() {
let a = self.prg_chip_addr(self.prg_bank as usize, addr);
- self.chip.write(&mut self.prg_rom, a, value);
+ if self.chip.write(&mut self.prg_rom, a, value) {
+ self.dirty = true;
+ }
}
} else {
// Submapper 0 w/o battery or submapper 2: the latch responds to the
@@ -1422,6 +1511,15 @@ impl Mapper for Unrom512M30 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1491,6 +1589,8 @@ impl Mapper for Unrom512M30 {
}
fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ // Marks the save data dirty; see `save_data_mut`.
+ self.dirty = true;
let chr_len = if self.chr_is_rom { 0 } else { self.chr.len() };
let fixed = 6 + self.vram.len() + chr_len;
if data.len() < fixed {
@@ -2097,6 +2197,36 @@ mod tests {
assert_eq!(m.cpu_read(0x8123), 0xFF);
}
+ /// v3.2.0: the two flash boards report a dirty flag, and while it reads
+ /// `Some(false)` the battery writer skips its comparison, so EVERY path
+ /// that can change the save data must set it -- not only a program
+ /// write. A restored state, a power-on clear and a direct
+ /// `save_data_mut` each change the flash without a program write.
+ #[test]
+ fn flash_boards_mark_every_save_data_change_dirty() {
+ fn check(m: &mut dyn Mapper, name: &str) {
+ assert_eq!(m.is_save_data_dirty(), Some(false), "{name}: fresh");
+ let state = m.save_state();
+ m.load_state(&state).unwrap();
+ assert_eq!(m.is_save_data_dirty(), Some(true), "{name}: load_state");
+ m.clear_save_data_dirty();
+ m.clear_save_data();
+ assert_eq!(
+ m.is_save_data_dirty(),
+ Some(true),
+ "{name}: clear_save_data"
+ );
+ m.clear_save_data_dirty();
+ let _ = m.save_data_mut();
+ assert_eq!(m.is_save_data_dirty(), Some(true), "{name}: save_data_mut");
+ }
+ check(&mut Gtrom111::new(synth_prg_32k(8), &[]).unwrap(), "GTROM");
+ check(
+ &mut Unrom512M30::new(synth_prg_16k(16), &[], false, true, 1, false).unwrap(),
+ "UNROM 512 (flashable)",
+ );
+ }
+
/// The reset a power-on movie performs (`power_on_for_movie` ->
/// `clear_save_data`): a flashed UNROM 512 goes back to the image as
/// loaded, byte for byte. Neither zeros (a ROM with no program in it) nor
diff --git a/crates/rustynes-mappers/src/jaleco_discrete.rs b/crates/rustynes-mappers/src/jaleco_discrete.rs
index 49b19cb02..954d1045a 100644
--- a/crates/rustynes-mappers/src/jaleco_discrete.rs
+++ b/crates/rustynes-mappers/src/jaleco_discrete.rs
@@ -130,6 +130,19 @@ impl Mapper for Jaleco86 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -258,6 +271,19 @@ impl Mapper for Jaleco140 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -530,6 +556,10 @@ impl Mapper for Jaleco72 {
self.inner.ppu_read(addr)
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ self.inner.ppu_read(addr)
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
self.inner.ppu_write(addr, value);
}
@@ -587,6 +617,10 @@ impl Mapper for Jaleco92 {
self.inner.ppu_read(addr)
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ self.inner.ppu_read(addr)
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
self.inner.ppu_write(addr, value);
}
@@ -680,6 +714,19 @@ impl Mapper for Jaleco101 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/kaiser.rs b/crates/rustynes-mappers/src/kaiser.rs
index 1872d2890..b1291b6bc 100644
--- a/crates/rustynes-mappers/src/kaiser.rs
+++ b/crates/rustynes-mappers/src/kaiser.rs
@@ -457,6 +457,26 @@ impl Mapper for KaiserMapper {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ if self.board == KaiserBoard::M56 {
+ let slot = (addr as usize) / CHR_BANK_1K;
+ let count = self.chr_count_1k();
+ let bank = (self.chr_banks[slot] as usize) % count;
+ return self.chr[bank * CHR_BANK_1K + (addr as usize & 0x3FF)];
+ }
+ self.chr[addr as usize & (self.chr.len() - 1)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/lib.rs b/crates/rustynes-mappers/src/lib.rs
index eefad19d4..6ea91c529 100644
--- a/crates/rustynes-mappers/src/lib.rs
+++ b/crates/rustynes-mappers/src/lib.rs
@@ -46,7 +46,9 @@ use alloc::{boxed::Box, string::ToString};
mod a12_filter;
mod bmc_simple;
mod cartridge;
+mod datach_barcode;
mod fds;
+mod ffe_super_magic_card;
mod header;
mod homebrew_boards;
mod jaleco_discrete;
@@ -110,6 +112,7 @@ mod m099_vs_system;
mod m105_nes_event;
mod m107_magic_dragon107;
mod m113_ave_nina006;
+mod m116_somari;
mod m118_txsrom;
mod m119_tqrom;
mod m132_txc_22211;
@@ -118,6 +121,7 @@ mod m151_konami_vs;
mod m152_bandai152;
mod m156_daou156;
mod m163_nanjing;
+mod m172_m173_txc;
mod m176_bmc_fk23c;
mod m177_hengedianzi;
mod m179_hengedianzi;
@@ -126,16 +130,20 @@ mod m184_sunsoft1;
mod m185_cnrom185;
mod m210_namco175;
mod m228_action52;
+mod m230_reset_multicart;
mod m232_camerica_bf9096;
+mod m235_golden_game;
mod m240_cne_multicart;
mod m241_bxrom241;
mod m244_cne_decathlon;
mod m246_fong_shen_bang246;
mod m250_nitra250;
+mod m252_waixing_vrc4;
mod m268_bmc_coolboy;
mod m513_sachen_9602;
mod mapper;
mod mmc3_boards;
+mod mmc3_boards_ext;
mod mmc3_clones;
mod multicart_discrete;
mod nsf;
@@ -150,10 +158,15 @@ mod waixing;
pub use bmc_simple::{new_m164, new_m261, new_m286, new_m289, new_m320, new_m336, new_m349};
pub use cartridge::{Cartridge, ConsoleType, Mirroring, Region, RomError, VsPpuPalette, VsPpuType};
+pub use datach_barcode::{
+ Barcode, BarcodeError, BarcodeReader, DARK_LEVEL as BARCODE_DARK_LEVEL,
+ MODULE_CYCLES as BARCODE_MODULE_CYCLES, QUIET_MODULES as BARCODE_QUIET_MODULES,
+};
pub use fds::{
DISK_BYTE_CYCLES, FDS_SIDE_LEN, Fds, FdsDisk, FdsQuirk, FdsTraceRec, HEAD_RESEEK_CYCLES,
fds_crc32, parse_fds, quirk_for_crc,
};
+pub use ffe_super_magic_card::{FfeBoard, SuperMagicCard};
pub use header::{
ExpansionDevice, ExtendedConsoleType, Header, VsHardwareType, parse_header,
serialize_header_preserving,
@@ -220,6 +233,7 @@ pub use m099_vs_system::VsSystem;
pub use m105_nes_event::{NWC_TOURNAMENT_DIP, NesEvent105};
pub use m107_magic_dragon107::MagicDragon107;
pub use m113_ave_nina006::Nina006M113;
+pub use m116_somari::Somari116;
pub use m118_txsrom::TxSrom;
pub use m119_tqrom::Tqrom;
pub use m132_txc_22211::Txc132;
@@ -228,6 +242,7 @@ pub use m151_konami_vs::KonamiVs;
pub use m152_bandai152::Bandai152;
pub use m156_daou156::Daou156;
pub use m163_nanjing::Nanjing163;
+pub use m172_m173_txc::{Txc172, Txc173};
pub use m176_bmc_fk23c::new_m176;
pub use m177_hengedianzi::Hengedianzi177;
pub use m179_hengedianzi::Hengedianzi179;
@@ -236,12 +251,15 @@ pub use m184_sunsoft1::Sunsoft1;
pub use m185_cnrom185::CnRom185;
pub use m210_namco175::{Namco175, Namco175Board};
pub use m228_action52::Action52M228;
+pub use m230_reset_multicart::ResetMulticart230;
pub use m232_camerica_bf9096::Camerica232;
+pub use m235_golden_game::GoldenGame235;
pub use m240_cne_multicart::Cne240;
pub use m241_bxrom241::Bxrom241;
pub use m244_cne_decathlon::Decathlon244;
pub use m246_fong_shen_bang246::FongShenBang246;
pub use m250_nitra250::Nitra250;
+pub use m252_waixing_vrc4::Waixing252;
pub use m268_bmc_coolboy::new_m268;
pub use m513_sachen_9602::new_m513;
pub use mapper::{
@@ -249,6 +267,7 @@ pub use mapper::{
mirroring_name,
};
pub use mmc3_boards::{Board as Mmc3BoardKind, Mmc3Board};
+pub use mmc3_boards_ext::{ExtBoard as Mmc3ExtBoardKind, Mmc3BoardExt};
pub use mmc3_clones::{
Mmc3CloneMapper, new_m44, new_m49, new_m52, new_m115, new_m134, new_m189, new_m205, new_m238,
new_m245, new_m348, new_m366,
@@ -301,6 +320,26 @@ fn mmc3_board(
))
}
+/// Build one of the v3.2.0 MMC3 boards (`mmc3_boards_ext.rs`). Work RAM is
+/// taken only from a NES 2.0 header, for the reason given on [`mmc3_board`]:
+/// iNES 1.0 reports a nominal 8 KiB for every image.
+fn mmc3_ext(
+ kind: Mmc3ExtBoardKind,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ h: &Header,
+) -> Result, RomError> {
+ let wram = if h.is_nes2 {
+ h.prg_ram_window() as usize
+ } else {
+ 0
+ };
+ Ok(Box::new(
+ Mmc3BoardExt::new(kind, prg_rom, chr_rom, h.mirroring, wram)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ))
+}
+
/// Parse an iNES 1.0 / NES 2.0 ROM file.
///
/// Validates the magic, detects the format, applies the appropriate sizing
@@ -360,13 +399,19 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
let mut cursor = header::HEADER_LEN;
if h.has_trainer {
- if bytes.len() < cursor + header::TRAINER_LEN {
+ let next = cursor
+ .checked_add(header::TRAINER_LEN)
+ .ok_or(RomError::Truncated {
+ needed: usize::MAX,
+ got: bytes.len(),
+ })?;
+ if bytes.len() < next {
return Err(RomError::Truncated {
- needed: cursor + header::TRAINER_LEN,
+ needed: next,
got: bytes.len(),
});
}
- cursor += header::TRAINER_LEN;
+ cursor = next;
}
if bytes.len() < cursor + h.prg_size {
@@ -588,17 +633,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
// We dispatch by (mapper, submapper) pair. Default mapping below
// is conservative; refer to nesdev wiki for the full table.
21 | 23 | 25 => {
- // Mapper 21 -> VRC4 only (a/c).
- // Mapper 22 -> VRC2a (no submapper).
- // Mapper 23 -> VRC2b/VRC4e/VRC4f.
- // Mapper 25 -> VRC2c/VRC4b/VRC4d.
- // We model VRC4 with CPU-cycle IRQ as the superset; submapper
- // selects pin-decoder variant. Banking matches between VRC2
- // and VRC4; the difference is mostly the IRQ counter which
- // VRC2 lacks (we just leave it idle).
- let m021_vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring)
- .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
- Box::new(m021_vrc4)
+ if (h.mapper_id == 23 || h.mapper_id == 25) && h.submapper == 3 {
+ let vrc2 = Vrc2::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
+ Box::new(vrc2)
+ } else {
+ let m021_vrc4 = Vrc4::new(prg_rom, chr_rom, h.mapper_id, h.submapper, h.mirroring)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
+ Box::new(m021_vrc4)
+ }
}
22 => {
let vrc2 = Vrc2::new(prg_rom, chr_rom, 22, h.submapper, h.mirroring)
@@ -631,11 +674,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
Box::new(fme7)
}
71 => {
- // Some Camerica boards (BF9097) have $9000 mirroring control
- // (subm 1). Default off.
+ // Fire Hawk's BF9097 has $9000 single-screen control: NES 2.0
+ // submapper 1 has it from power-on; submapper 0 (every iNES 1.0
+ // image included) detects it at the first $9000 write, per the
+ // NESdev page's compatibility advice; other submappers keep the
+ // header mirroring.
let has_single_screen = h.is_nes2 && h.submapper == 1;
let cam = Camerica::new(prg_rom, h.mirroring, has_single_screen)
- .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?
+ .with_single_screen_detection(h.submapper == 0);
Box::new(cam)
}
75 => {
@@ -673,6 +720,15 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
.map_err(|e| RomError::InvalidConfig(e.to_string()))?;
Box::new(fcg)
}
+ 157 => {
+ // v3.2.0: the Datach Joint ROM System (`INES_Mapper_157`). The
+ // NES 2.0 PRG-NVRAM field declares only the game cartridge's own
+ // X24C01 (byte 10 `$10`, *Battle Rush*); the main unit's 24C02 is
+ // always present and not counted there.
+ let fcg = BandaiFcg::new_datach(prg_rom, chr_rom, h.mirroring, h.prg_nvram_size > 0)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?;
+ Box::new(fcg)
+ }
159 => {
// Bandai LZ93D50 with a 128-byte X24C01 serial EEPROM.
let fcg = BandaiFcg::new(prg_rom, chr_rom, h.mirroring, FcgVariant::Lz93d50_24c01)
@@ -1377,6 +1433,41 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
192 => mmc3_board(Mmc3BoardKind::M192, prg_rom, chr_rom, &h)?,
194 => mmc3_board(Mmc3BoardKind::M194, prg_rom, chr_rom, &h)?,
195 => mmc3_board(Mmc3BoardKind::M195, prg_rom, chr_rom, &h)?,
+ // v3.2.0: the SuperGame / Hosenkan scramblers (`mmc3_boards_ext.rs`).
+ // 182 is a duplicate of 114 (`INES_Mapper_114.md`, "Similar
+ // Mappers"; the 182 page is a redirect), so it takes submapper 0.
+ 114 => mmc3_ext(
+ Mmc3ExtBoardKind::M114 {
+ sub1: h.submapper == 1,
+ },
+ prg_rom,
+ chr_rom,
+ &h,
+ )?,
+ 182 => mmc3_ext(Mmc3ExtBoardKind::M114 { sub1: false }, prg_rom, chr_rom, &h)?,
+ // MMC3 with 2 / 4 KiB CHR granularity; the submapper is the wiring.
+ 197 => mmc3_ext(
+ Mmc3ExtBoardKind::M197 { sub: h.submapper },
+ prg_rom,
+ chr_rom,
+ &h,
+ )?,
+ // SOMARI-P (Huang-1 / Huang-2): VRC2 / MMC3 / MMC1 by a supervisor.
+ 116 => Box::new(
+ Somari116::new(prg_rom, chr_rom, h.mirroring)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // MMC3 + MMC2-style CHR latches and a 4 KiB CHR-RAM page.
+ 165 => mmc3_ext(Mmc3ExtBoardKind::M165, prg_rom, chr_rom, &h)?,
+ // Sugar Softec UNL-8237 (215.0) / UNL-8237A (215.1).
+ 215 => mmc3_ext(
+ Mmc3ExtBoardKind::M215 {
+ sub1: h.submapper == 1,
+ },
+ prg_rom,
+ chr_rom,
+ &h,
+ )?,
44 => Box::new(
new_m44(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
@@ -1567,6 +1658,76 @@ pub fn parse(bytes: &[u8]) -> Result<(Cartridge, Box), RomError> {
new_m299(prg_rom, chr_rom, h.mirroring)
.map_err(|e| RomError::InvalidConfig(e.to_string()))?,
),
+ // --- v3.2.0: boards written from their NESdev pages (no
+ // reference-emulator source read). Tiers and evidence in `tier.rs`
+ // and `docs/mappers.md`.
+ //
+ // 172 / 173: TXC / Idea-Tek protection-chip boards
+ // (`m172_m173_txc.rs`). 172 controls its own mirroring; 173's is
+ // hard-wired, so the header's is passed for both as the power-on value.
+ 172 => Box::new(
+ Txc172::new(prg_rom, chr_rom, h.mirroring)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ 173 => Box::new(
+ Txc173::new(prg_rom, chr_rom, h.mirroring)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // 158: Tengen 800037 (*Alien Syndrome*), a RAMBO-1 whose CHR A17
+ // drives CIRAM A10 per nametable (`m064_rambo1.rs`, `Rambo1::new_m158`).
+ 158 => Box::new(
+ Rambo1::new_m158(prg_rom, chr_rom)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // 207: Taito X1-005 with CHR A17 on CIRAM A10 (*Fudou Myouou Den*;
+ // `m080_taito_x1_005.rs`, `TaitoX1005::new_m207`).
+ 207 => Box::new(
+ TaitoX1005::new_m207(prg_rom, chr_rom)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // 252: Waixing VRC4e clone with CHR-RAM pages 6 and 7 (*San Guo Zhi*;
+ // `m252_waixing_vrc4.rs`).
+ 252 => Box::new(
+ Waixing252::new(prg_rom, chr_rom, h.mirroring)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // 230: the reset-switched 22-in-1 (Contra + multicart); the console
+ // RESET toggles the chip (`m230_reset_multicart.rs`).
+ 230 => Box::new(
+ ResetMulticart230::new(prg_rom, &chr_rom)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // 235: the "Golden Game" address-latch multicart with four 1 MiB
+ // sockets (`m235_golden_game.rs`).
+ 235 => Box::new(
+ GoldenGame235::new(prg_rom, &chr_rom)
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ ),
+ // 6 / 8 / 17: games extracted from FFE Magic Card / Super Magic Card
+ // disks, run on a model of the card (`ffe_super_magic_card.rs`). The
+ // iNES number and submapper pick only the card's initial registers;
+ // the Doctor-format trainer, which `parse` otherwise skips, is handed
+ // to the card to load and call.
+ 6 | 8 | 17 => {
+ let trainer = h
+ .has_trainer
+ .then(|| &bytes[header::HEADER_LEN..header::HEADER_LEN + header::TRAINER_LEN]);
+ let board = match h.mapper_id {
+ 8 => FfeBoard::MagicCard(4),
+ 6 => FfeBoard::MagicCard(if h.is_nes2 { h.submapper & 0x07 } else { 1 }),
+ _ => FfeBoard::SuperMagicCard(if h.is_nes2 { h.submapper & 0x03 } else { 0 }),
+ };
+ Box::new(
+ SuperMagicCard::new(
+ board,
+ &prg_rom,
+ &chr_rom,
+ trainer,
+ h.mirroring == Mirroring::Vertical,
+ )
+ .map_err(|e| RomError::InvalidConfig(e.to_string()))?,
+ )
+ }
other => return Err(RomError::UnsupportedMapper(other)),
};
diff --git a/crates/rustynes-mappers/src/m000_nrom.rs b/crates/rustynes-mappers/src/m000_nrom.rs
index 8e6a0c03a..bab076d9d 100644
--- a/crates/rustynes-mappers/src/m000_nrom.rs
+++ b/crates/rustynes-mappers/src/m000_nrom.rs
@@ -184,6 +184,21 @@ impl Mapper for Nrom {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => {
+ let off = self.nametable_offset(addr);
+ self.vram[off]
+ }
+ // $3F00-$3FFF is palette RAM; owned by the PPU, not the mapper.
+ // Returning 0 here matches the behavior expected of mappers that
+ // never see palette accesses (PPU short-circuits).
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m001_mmc1.rs b/crates/rustynes-mappers/src/m001_mmc1.rs
index d91bb7f2f..7a666fcfa 100644
--- a/crates/rustynes-mappers/src/m001_mmc1.rs
+++ b/crates/rustynes-mappers/src/m001_mmc1.rs
@@ -179,6 +179,15 @@ impl Mmc1 {
self.shift_count
}
+ /// Empty the serial port's shift register without touching `control`
+ /// (unlike a bit-7 write, which also forces PRG mode 3). Mapper 116's
+ /// PAL does this when its supervisor register switches the board into
+ /// MMC1 mode (`INES_Mapper_116.md`); v3.2.0.
+ pub(crate) const fn reset_shift_register(&mut self) {
+ self.shift = 0x10;
+ self.shift_count = 0;
+ }
+
/// Map a PPU address in `$2000-$3EFF` to a 2 KiB-VRAM offset using the
/// current mirroring mode (control bits 1-0).
fn nametable_offset(&self, addr: u16) -> usize {
@@ -479,6 +488,21 @@ impl Mapper for Mmc1 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.map_chr(addr);
+ self.chr[off]
+ }
+ 0x2000..=0x3EFF => {
+ let off = self.nametable_offset(addr);
+ self.vram[off]
+ }
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m002_uxrom.rs b/crates/rustynes-mappers/src/m002_uxrom.rs
index 5c50eb88d..588b78cc2 100644
--- a/crates/rustynes-mappers/src/m002_uxrom.rs
+++ b/crates/rustynes-mappers/src/m002_uxrom.rs
@@ -40,6 +40,8 @@ pub struct UxRom {
vram: Box<[u8]>,
chr_is_ram: bool,
bank: u8,
+ bank_offset: usize,
+ last_bank_offset: usize,
mirroring: Mirroring,
}
@@ -75,12 +77,16 @@ impl UxRom {
chr_rom.len()
)));
};
+ let bank_count = (prg_rom.len() / PRG_BANK_16K).max(1);
+ let last_bank_offset = (bank_count - 1) * PRG_BANK_16K;
Ok(Self {
prg_rom,
chr,
vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
chr_is_ram,
bank: 0,
+ bank_offset: 0,
+ last_bank_offset,
mirroring,
})
}
@@ -91,6 +97,12 @@ impl UxRom {
let physical = self.mirroring.physical_bank(table);
physical * NAMETABLE_SIZE + local
}
+ #[inline]
+ fn update_bank_offsets(&mut self) {
+ let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
+ self.bank_offset = ((self.bank as usize) % bank_count) * PRG_BANK_16K;
+ self.last_bank_offset = (bank_count - 1) * PRG_BANK_16K;
+ }
}
impl Mapper for UxRom {
@@ -103,16 +115,12 @@ impl Mapper for UxRom {
fn cpu_read(&mut self, addr: u16) -> u8 {
match addr {
0x8000..=0xBFFF => {
- let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
- let bank = (self.bank as usize) % bank_count;
let off = (addr - 0x8000) as usize;
- self.prg_rom[bank * PRG_BANK_16K + off]
+ self.prg_rom[self.bank_offset + off]
}
0xC000..=0xFFFF => {
- let bank_count = (self.prg_rom.len() / PRG_BANK_16K).max(1);
- let last = bank_count - 1;
let off = (addr - 0xC000) as usize;
- self.prg_rom[last * PRG_BANK_16K + off]
+ self.prg_rom[self.last_bank_offset + off]
}
_ => 0,
}
@@ -129,6 +137,7 @@ impl Mapper for UxRom {
// the bank register is u8.
let mask = u8::try_from((bank_count - 1) | 0x0F).unwrap_or(u8::MAX);
self.bank = value & mask;
+ self.update_bank_offsets();
}
}
@@ -150,6 +159,15 @@ impl Mapper for UxRom {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -201,6 +219,7 @@ impl Mapper for UxRom {
return Err(MapperError::UnsupportedVersion(data[0]));
}
self.bank = data[1];
+ self.update_bank_offsets();
let mut cursor = 2;
self.vram
.copy_from_slice(&data[cursor..cursor + self.vram.len()]);
diff --git a/crates/rustynes-mappers/src/m003_cnrom.rs b/crates/rustynes-mappers/src/m003_cnrom.rs
index 7192660d5..807cdada4 100644
--- a/crates/rustynes-mappers/src/m003_cnrom.rs
+++ b/crates/rustynes-mappers/src/m003_cnrom.rs
@@ -26,6 +26,7 @@ pub struct CnRom {
chr_rom: Box<[u8]>,
vram: Box<[u8]>,
chr_bank: u8,
+ chr_bank_offset: usize,
mirroring: Mirroring,
}
@@ -58,6 +59,7 @@ impl CnRom {
chr_rom,
vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
chr_bank: 0,
+ chr_bank_offset: 0,
mirroring,
})
}
@@ -69,6 +71,12 @@ impl CnRom {
physical * NAMETABLE_SIZE + local
}
+ #[inline]
+ fn update_bank_offsets(&mut self) {
+ let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ self.chr_bank_offset = ((self.chr_bank as usize) % bank_count) * CHR_BANK_8K;
+ }
+
fn read_prg(&self, addr: u16) -> u8 {
let off = (addr - 0x8000) as usize;
if self.prg_rom.len() == PRG_BANK_16K {
@@ -105,24 +113,28 @@ impl Mapper for CnRom {
let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
let mask = u8::try_from((bank_count - 1) | 0x03).unwrap_or(u8::MAX);
self.chr_bank = effective & mask;
+ self.update_bank_offsets();
}
}
fn chr_phys(&self, addr: u16) -> Option {
// CNROM is CHR-ROM only; the same 8 KiB-bank offset `ppu_read` resolves.
- let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
- let bank = usize::from(self.chr_bank) % bank_count;
- u32::try_from(bank * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok()
+ u32::try_from(self.chr_bank_offset + usize::from(addr & 0x1FFF)).ok()
}
fn ppu_read(&mut self, addr: u16) -> u8 {
let addr = addr & 0x3FFF;
match addr {
- 0x0000..=0x1FFF => {
- let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
- let bank = (self.chr_bank as usize) % bank_count;
- self.chr_rom[bank * CHR_BANK_8K + (addr as usize)]
- }
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)],
0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
_ => 0,
}
@@ -166,6 +178,7 @@ impl Mapper for CnRom {
return Err(MapperError::UnsupportedVersion(data[0]));
}
self.chr_bank = data[1];
+ self.update_bank_offsets();
self.vram.copy_from_slice(&data[2..2 + self.vram.len()]);
Ok(())
}
diff --git a/crates/rustynes-mappers/src/m004_mmc3.rs b/crates/rustynes-mappers/src/m004_mmc3.rs
index 3e853936a..8129dff90 100644
--- a/crates/rustynes-mappers/src/m004_mmc3.rs
+++ b/crates/rustynes-mappers/src/m004_mmc3.rs
@@ -154,6 +154,8 @@ pub struct Mmc3 {
// CHR mode (bit 7 of $8000): 0 = 2K@$0000 + 1K@$1000;
// 1 = 1K@$0000 + 2K@$1000.
chr_mode: bool,
+ prg_offsets: [usize; 4],
+ chr_offsets: [usize; 8],
// Mirroring (set via $A000 even). Ignored on 4-screen carts.
mirroring: Mirroring,
@@ -306,7 +308,7 @@ impl Mmc3 {
} else {
2 * NAMETABLE_SIZE
};
- Ok(Self {
+ let mut mapper = Self {
prg_rom,
chr,
prg_ram: vec![0u8; prg_ram_size].into_boxed_slice(),
@@ -316,6 +318,8 @@ impl Mmc3 {
bank_select: 0,
prg_mode: false,
chr_mode: false,
+ prg_offsets: [0; 4],
+ chr_offsets: [0; 8],
mirroring: initial_mirroring,
fixed_4screen,
prg_ram_enabled: true,
@@ -337,7 +341,10 @@ impl Mmc3 {
mcacc_prescaler: 0,
#[cfg(feature = "mmc3-a12-phase-probe")]
probe: Mmc3A12PhaseProbe::default(),
- })
+ };
+ mapper.update_prg_offsets();
+ mapper.update_chr_offsets();
+ Ok(mapper)
}
/// Select the board wiring (the NES 2.0 submapper), v2.9.6. An MMC6 gets
@@ -428,25 +435,40 @@ impl Mmc3 {
self.prg_ram_enabled && !self.prg_ram_protect
}
- /// Resolve a CPU PRG address (`$8000-$FFFF`) to a byte offset in
- /// `prg_rom`. Implements PRG modes 0 and 1.
- fn prg_offset(&self, addr: u16) -> usize {
+ /// Bank register R`r` (`r` = 0-7) exactly as written through `$8001`,
+ /// with no CHR-inversion or 2 KiB pairing applied. For boards that use
+ /// the MMC3's registers in a different banking scheme (mapper 165 reads
+ /// R0 / R1 / R2 / R4 as 4 KiB pages, MMC2 style); v3.2.0.
+ pub(crate) const fn bank_register(&self, r: usize) -> u8 {
+ self.regs[r & 7]
+ }
+
+ /// Rebuild the four 8 KiB PRG slot offsets from R6 / R7 and the PRG
+ /// mode. Called on every write that changes them, at construction and
+ /// from `load_state`, so `prg_offset` is a table lookup.
+ ///
+ /// R6 / R7 are MASKED to the image (`& last`, the address lines the
+ /// board wires), never reduced modulo the bank count: the two differ for
+ /// a non-power-of-two image (`bank_numbers_are_masked_not_reduced_modulo`).
+ /// Every offset is in range because the constructor requires a non-empty
+ /// multiple of 8 KiB, so `prg_rom` needs no `% len` at the read.
+ fn update_prg_offsets(&mut self) {
let total_banks = self.prg_rom.len() / PRG_BANK_8K;
let last = total_banks.saturating_sub(1);
let second_last = total_banks.saturating_sub(2);
- // R6/R7 are masked to total_banks (typical sizes <= 64 banks => 6 bits).
let r6 = (self.regs[6] as usize) & last;
let r7 = (self.regs[7] as usize) & last;
- let bank = match (addr & 0xE000, self.prg_mode) {
- (0x8000, false) => r6,
- (0x8000, true) => second_last,
- (0xA000, _) => r7,
- (0xC000, false) => second_last,
- (0xC000, true) => r6,
- (0xE000, _) => last,
- _ => 0,
- };
- bank * PRG_BANK_8K + ((addr as usize) & 0x1FFF)
+ self.prg_offsets[0] = (if !self.prg_mode { r6 } else { second_last }) * PRG_BANK_8K;
+ self.prg_offsets[1] = r7 * PRG_BANK_8K;
+ self.prg_offsets[2] = (if !self.prg_mode { second_last } else { r6 }) * PRG_BANK_8K;
+ self.prg_offsets[3] = last * PRG_BANK_8K;
+ }
+
+ /// Resolve a CPU PRG address (`$8000-$FFFF`) to a byte offset in
+ /// `prg_rom`. Implements PRG modes 0 and 1.
+ fn prg_offset(&self, addr: u16) -> usize {
+ let idx = ((addr & 0xE000) - 0x8000) >> 13;
+ self.prg_offsets[idx as usize] + ((addr as usize) & 0x1FFF)
}
/// Resolve a PPU CHR address (`$0000-$1FFF`) to the **raw** 1 KiB CHR
@@ -496,20 +518,23 @@ impl Mmc3 {
}
}
+ /// Rebuild the eight 1 KiB CHR slot offsets from R0-R5 and the CHR mode
+ /// (layout in [`Self::chr_bank_1k_for_slot`]). Masked like the PRG table:
+ /// `& (banks - 1)`, not `%`. CHR is a non-zero multiple of 1 KiB (CHR-RAM
+ /// is 8 KiB), so every offset is in range.
+ fn update_chr_offsets(&mut self) {
+ let mask = (self.chr.len() / CHR_BANK_1K).saturating_sub(1);
+ for slot in 0..8 {
+ let bank_1k = self.chr_bank_1k_for_slot(slot);
+ self.chr_offsets[slot] = (bank_1k & mask) * CHR_BANK_1K;
+ }
+ }
+
/// Resolve a PPU CHR address (`$0000-$1FFF`) to an offset in `chr`.
fn chr_offset(&self, addr: u16) -> usize {
let addr = (addr & 0x1FFF) as usize;
- let total_banks_1k = self.chr.len() / CHR_BANK_1K;
- let mask = total_banks_1k.saturating_sub(1);
- // Slot index in 1K units (0..8).
let slot = addr / CHR_BANK_1K;
- // chr_mode false (0): 2K + 2K + 1K + 1K + 1K + 1K
- // R0/R0+1 R1/R1+1 R2 R3 R4 R5
- // chr_mode true (1): 1K + 1K + 1K + 1K + 2K + 2K
- // R2 R3 R4 R5 R0/R0+1 R1/R1+1
- let bank_1k = self.chr_bank_1k_for_slot(slot);
- let bank = bank_1k & mask;
- bank * CHR_BANK_1K + (addr & (CHR_BANK_1K - 1))
+ self.chr_offsets[slot] + (addr & (CHR_BANK_1K - 1))
}
/// Compute the CIRAM byte offset for a PPU address in
@@ -649,7 +674,7 @@ impl Mapper for Mmc3 {
}
0x8000..=0xFFFF => {
let off = self.prg_offset(addr);
- self.prg_rom[off % self.prg_rom.len()]
+ self.prg_rom[off]
}
_ => 0,
}
@@ -677,8 +702,16 @@ impl Mapper for Mmc3 {
if addr & 1 == 0 {
// $8000 even: bank-select.
self.bank_select = value & 0x07;
- self.prg_mode = (value & 0x40) != 0;
- self.chr_mode = (value & 0x80) != 0;
+ let new_prg = (value & 0x40) != 0;
+ let new_chr = (value & 0x80) != 0;
+ if self.prg_mode != new_prg {
+ self.prg_mode = new_prg;
+ self.update_prg_offsets();
+ }
+ if self.chr_mode != new_chr {
+ self.chr_mode = new_chr;
+ self.update_chr_offsets();
+ }
if self.variant == Mmc3Variant::Mmc6 {
// "When PRG RAM is disabled via $8000, the mapper
// continuously sets $A001 to $00".
@@ -689,7 +722,15 @@ impl Mapper for Mmc3 {
}
} else {
// $8001 odd: bank-data.
- self.regs[(self.bank_select & 0x07) as usize] = value;
+ let reg = (self.bank_select & 0x07) as usize;
+ if self.regs[reg] != value {
+ self.regs[reg] = value;
+ if reg >= 6 {
+ self.update_prg_offsets();
+ } else {
+ self.update_chr_offsets();
+ }
+ }
}
}
0xA000..=0xBFFF => {
@@ -754,7 +795,19 @@ impl Mapper for Mmc3 {
match addr {
0x0000..=0x1FFF => {
let off = self.chr_offset(addr);
- self.chr[off % self.chr.len()]
+ self.chr[off]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off]
}
0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
_ => 0,
@@ -1022,6 +1075,11 @@ impl Mapper for Mmc3 {
self.bank_select = data[9];
self.prg_mode = data[10] != 0;
self.chr_mode = data[11] != 0;
+ // The offset tables depend on exactly these fields. Rebuild them now,
+ // before any later check can return an error, so a refused state
+ // never leaves the tables describing different registers.
+ self.update_prg_offsets();
+ self.update_chr_offsets();
self.mirroring = match data[12] {
0 => Mirroring::Horizontal,
1 => Mirroring::Vertical,
@@ -1120,6 +1178,32 @@ mod tests {
.unwrap()
}
+ /// Bank numbers are MASKED to the image (`& (banks - 1)`, the address
+ /// lines a board actually wires), not reduced modulo the bank count. The
+ /// two agree for power-of-two images; for three 8 KiB PRG banks and three
+ /// 1 KiB CHR banks, register value 3 masks to bank 2 but is bank 0 modulo
+ /// 3. Regression for a v3.2.0 import whose cached offset tables used `%`.
+ /// The tables must also follow a restored state, including when
+ /// `load_state` fails after the registers were already written.
+ #[test]
+ fn bank_numbers_are_masked_not_reduced_modulo() {
+ let mut m = fresh(3, 3);
+ m.cpu_write(0x8000, 6); // select R6 (PRG at $8000 in mode 0)
+ m.cpu_write(0x8001, 3);
+ assert_eq!(m.cpu_read(0x8000), 2, "PRG: 3 & 2 = bank 2");
+ m.cpu_write(0x8000, 2); // select R2 (1 KiB CHR at $1000 in mode 0)
+ m.cpu_write(0x8001, 3);
+ assert_eq!(m.ppu_read(0x1000), 2, "CHR: 3 & 2 = bank 2");
+ assert_eq!(m.ppu_peek(0x1000), 2, "and through the peek path");
+
+ // A restore into a fresh board rebuilds the tables.
+ let state = m.save_state();
+ let mut other = fresh(3, 3);
+ other.load_state(&state).unwrap();
+ assert_eq!(other.cpu_read(0x8000), 2);
+ assert_eq!(other.ppu_read(0x1000), 2);
+ }
+
#[test]
fn last_8k_bank_fixed_at_e000() {
let mut m = fresh(8, 8);
diff --git a/crates/rustynes-mappers/src/m005_mmc5.rs b/crates/rustynes-mappers/src/m005_mmc5.rs
index 6de24c5a8..9c106ea0b 100644
--- a/crates/rustynes-mappers/src/m005_mmc5.rs
+++ b/crates/rustynes-mappers/src/m005_mmc5.rs
@@ -324,7 +324,7 @@ impl Mmc5Pulse {
/// Per-cycle 4-bit output (0..=15). 0 when muted.
pub(crate) fn output(&self) -> u8 {
- if self.muted() || DUTY_TABLE[self.duty as usize][self.step as usize] == 0 {
+ if DUTY_TABLE[self.duty as usize][self.step as usize] == 0 || self.muted() {
0
} else {
self.envelope_output()
@@ -1290,6 +1290,16 @@ impl Mapper for Mmc5 {
}
fn ppu_read(&mut self, addr: u16) -> u8 {
+ // MMC5 CHR / nametable reads have no side effect here: its scanline
+ // detection runs from `notify_scanline_start` / `notify_vblank`. A
+ // v3.2.0 import moved a nametable-snoop detector into this method,
+ // but the lockstep bus never routes nametable fetches through
+ // `ppu_read` (they go via `peek_nametable`), so the IRQ stopped
+ // firing in real play; reverted (`external_mmc5_*` caught it).
+ self.ppu_peek(addr)
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
let addr = addr & 0x3FFF;
match addr {
0x0000..=0x1FFF => {
diff --git a/crates/rustynes-mappers/src/m007_axrom.rs b/crates/rustynes-mappers/src/m007_axrom.rs
index d0e810a49..52b534f6d 100644
--- a/crates/rustynes-mappers/src/m007_axrom.rs
+++ b/crates/rustynes-mappers/src/m007_axrom.rs
@@ -27,6 +27,7 @@ pub struct AxRom {
vram: Box<[u8]>,
chr_is_ram: bool,
bank: u8,
+ bank_offset: usize,
mirroring_b: bool,
}
@@ -60,6 +61,7 @@ impl AxRom {
vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
chr_is_ram,
bank: 0,
+ bank_offset: 0,
mirroring_b: false,
})
}
@@ -69,6 +71,11 @@ impl AxRom {
let physical = usize::from(self.mirroring_b);
physical * NAMETABLE_SIZE + local
}
+ #[inline]
+ fn update_bank_offsets(&mut self) {
+ let bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ self.bank_offset = ((self.bank as usize) % bank_count) * PRG_BANK_32K;
+ }
}
impl Mapper for AxRom {
@@ -80,10 +87,8 @@ impl Mapper for AxRom {
fn cpu_read(&mut self, addr: u16) -> u8 {
if (0x8000..=0xFFFF).contains(&addr) {
- let bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
- let bank = (self.bank as usize) % bank_count;
let off = (addr - 0x8000) as usize;
- self.prg_rom[bank * PRG_BANK_32K + off]
+ self.prg_rom[self.bank_offset + off]
} else {
0
}
@@ -93,6 +98,7 @@ impl Mapper for AxRom {
if (0x8000..=0xFFFF).contains(&addr) {
self.bank = value & 0x07;
self.mirroring_b = (value & 0x10) != 0;
+ self.update_bank_offsets();
}
}
@@ -114,6 +120,15 @@ impl Mapper for AxRom {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -166,6 +181,7 @@ impl Mapper for AxRom {
}
self.bank = data[1];
self.mirroring_b = data[2] != 0;
+ self.update_bank_offsets();
let mut cursor = 3;
self.vram
.copy_from_slice(&data[cursor..cursor + self.vram.len()]);
diff --git a/crates/rustynes-mappers/src/m009_mmc2.rs b/crates/rustynes-mappers/src/m009_mmc2.rs
index 2ca72d159..4860efefa 100644
--- a/crates/rustynes-mappers/src/m009_mmc2.rs
+++ b/crates/rustynes-mappers/src/m009_mmc2.rs
@@ -137,7 +137,7 @@ impl Mmc2 {
bank * PRG_BANK_8K + ((addr as usize) & 0x1FFF)
}
- fn chr_offset(&mut self, addr: u16) -> usize {
+ fn chr_offset(&self, addr: u16) -> usize {
let addr = (addr & 0x1FFF) as usize;
let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
let bank = if addr < CHR_BANK_4K {
@@ -227,6 +227,18 @@ impl Mapper for Mmc2 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m010_mmc4.rs b/crates/rustynes-mappers/src/m010_mmc4.rs
index 7a0a771ae..2202e7f29 100644
--- a/crates/rustynes-mappers/src/m010_mmc4.rs
+++ b/crates/rustynes-mappers/src/m010_mmc4.rs
@@ -133,7 +133,7 @@ impl Mmc4 {
bank * PRG_BANK_16K + ((addr as usize) & 0x3FFF)
}
- fn chr_offset(&mut self, addr: u16) -> usize {
+ fn chr_offset(&self, addr: u16) -> usize {
let addr = (addr & 0x1FFF) as usize;
let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
let bank = if addr < CHR_BANK_4K {
@@ -239,6 +239,18 @@ impl Mapper for Mmc4 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m011_color_dreams.rs b/crates/rustynes-mappers/src/m011_color_dreams.rs
index 6b18e8c3c..122e1a6bc 100644
--- a/crates/rustynes-mappers/src/m011_color_dreams.rs
+++ b/crates/rustynes-mappers/src/m011_color_dreams.rs
@@ -145,6 +145,19 @@ impl Mapper for ColorDreams {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let total_8k = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % total_8k;
+ self.chr_rom[(bank * CHR_BANK_8K + addr as usize) % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m013_cprom.rs b/crates/rustynes-mappers/src/m013_cprom.rs
index 448b6a39f..6b2e03d7b 100644
--- a/crates/rustynes-mappers/src/m013_cprom.rs
+++ b/crates/rustynes-mappers/src/m013_cprom.rs
@@ -105,6 +105,20 @@ impl Mapper for Cprom {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x0FFF => self.chr_ram[addr as usize],
+ 0x1000..=0x1FFF => {
+ let bank = (self.chr_bank as usize) & 0x03;
+ let off = bank * CHR_BANK_4K + (addr as usize - 0x1000);
+ self.chr_ram[off % self.chr_ram.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m016_bandai_fcg.rs b/crates/rustynes-mappers/src/m016_bandai_fcg.rs
index 1533717af..fdc8846c6 100644
--- a/crates/rustynes-mappers/src/m016_bandai_fcg.rs
+++ b/crates/rustynes-mappers/src/m016_bandai_fcg.rs
@@ -2,7 +2,7 @@
//
// Provenance: the Bandai FCG serial-EEPROM handling (`Eeprom24C01` / `Eeprom24C02`) is derived from Mesen2 (GPL-3.0-or-later), `Core/NES/Mappers/Bandai/`. See docs/originality-and-provenance.md (Section 1)
// and NOTICE for the complete, audited derivation record.
-//! Bandai FCG (iNES mappers 16 and 159) implementation.
+//! Bandai FCG (iNES mappers 16, 153, 157 and 159) implementation.
//!
//! Covers the Bandai FCG-1/FCG-2 and LZ93D50 ASICs. Banking: a 16 KiB
//! switchable PRG bank at `$8000-$BFFF` (last bank fixed at `$C000`), eight
@@ -33,6 +33,19 @@
//! freezes when it boots with zero-filled WRAM and runs after a soft reset;
//! that is the game, not the board.
//!
+//! Mapper 157 (v3.2.0, written from `nesdev_wiki/INES_Mapper_157.xhtml`) is
+//! the Datach Joint ROM System: an LZ93D50 game cartridge plugged into the
+//! Datach main unit. Banking, mirroring and the IRQ are mapper 16 submapper
+//! 5's; CHR is 8 KiB of unbanked CHR-RAM. The main unit holds a 256-byte
+//! 24C02 (clocked by `$800D` bit 5) and the barcode reader; *Battle Rush*'s
+//! cartridge adds a 128-byte X24C01 clocked by `$8000-$8003` bit 3 (CHR A13
+//! OUT; `$8004-$8007` do nothing). Both chips share SDA (`$800D` bit 6) and
+//! their outputs are ANDed onto bit 4 of a `$6000-$7FFF` read; bit 3 of the
+//! same read is the scanner ([`BarcodeReader`], `datach_barcode.rs`). The variant's code
+//! lives outside the derived EEPROM region below, except for the
+//! `keeps_word_address` flag on [`Eeprom`], written from the 24C02's
+//! random-read protocol (see the field) and on for every 24C02.
+//!
//! ## Offsets (relative to the window base, masked to `$x..F`)
//!
//! | Offset | Function |
@@ -74,6 +87,7 @@
)]
use crate::cartridge::Mirroring;
+use crate::datach_barcode::{Barcode, BarcodeReader};
use crate::mapper::{Mapper, MapperCaps, MapperError};
use alloc::{boxed::Box, vec::Vec};
use alloc::{format, vec};
@@ -86,7 +100,12 @@ const NAMETABLE_SIZE_U16: u16 = 0x0400;
/// v2 (v2.9.6) appends mapper 153's outer bank, WRAM enable and WRAM. Since
/// v2.9.8 (ADR 0042) a v1 blob is refused; it used to load on the variants
/// that have neither.
-const SAVE_STATE_VERSION: u8 = 2;
+///
+/// v3 (v3.2.0) adds each EEPROM's protocol state ([`Eeprom::save_lines`],
+/// nine bytes after its contents), so a state taken mid-transaction resumes
+/// it. A v2 blob is refused: on mappers 16 (with an EEPROM), 157 and 159, a
+/// v3.1.x save state does not load.
+const SAVE_STATE_VERSION: u8 = 3;
/// Mapper 153's WRAM.
const WRAM_153: usize = 0x2000;
@@ -108,6 +127,11 @@ pub enum FcgVariant {
/// Mapper 153: LZ93D50 with 8 KiB of battery-backed WRAM and an outer
/// 256 KiB PRG bank in place of the CHR registers.
Lz93d50Wram,
+ /// Mapper 157: the Datach Joint ROM System. LZ93D50, unbanked CHR-RAM,
+ /// the main unit's 256-byte 24C02, an optional 128-byte X24C01 on the
+ /// game cartridge, and the barcode reader. Built by
+ /// [`BandaiFcg::new_datach`].
+ Datach,
}
impl FcgVariant {
@@ -117,7 +141,11 @@ impl FcgVariant {
const fn responds_high(self) -> bool {
matches!(
self,
- Self::Both | Self::Lz93d50_24c02 | Self::Lz93d50_24c01 | Self::Lz93d50Wram
+ Self::Both
+ | Self::Lz93d50_24c02
+ | Self::Lz93d50_24c01
+ | Self::Lz93d50Wram
+ | Self::Datach
)
}
/// LZ93D50 latches the IRQ counter (`$x0B/$x0C` write a latch); FCG-1/2
@@ -128,7 +156,7 @@ impl FcgVariant {
const fn eeprom_bytes(self) -> usize {
match self {
Self::Lz93d50_24c01 => 128,
- Self::Both | Self::Lz93d50_24c02 => 256,
+ Self::Both | Self::Lz93d50_24c02 | Self::Datach => 256,
Self::Fcg | Self::Lz93d50Wram => 0,
}
}
@@ -180,6 +208,20 @@ struct Eeprom {
counter: u8,
/// The bit currently driven back onto SDA toward the CPU (high = 1).
output: u8,
+ /// v3.2.0, every 24C02: a START keeps the word address instead of
+ /// clearing it. A 24C02 "random read" is a dummy write of the word address
+ /// followed by a repeated START and a read-mode device select, and the
+ /// read then begins at that address: the chip's address counter is not
+ /// reset by a START. Clearing it made every random read start at `$00`.
+ /// Found on mapper 157: *Ultraman Club - Supokon Fight!* reads its file
+ /// table that way, and with the address cleared it reformatted the main
+ /// unit's EEPROM and stopped on "no free block" at every boot; keeping it,
+ /// the game reaches its title (measured 2026-10-08). Then applied to
+ /// mapper 16's 24C02 as well, since it is the chip's behaviour, not the
+ /// board's. Written from that protocol description.
+ /// Off for the X24C01, which takes its word address after every START, so
+ /// the clearing is unobservable there.
+ keeps_word_address: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -193,6 +235,35 @@ enum I2cMode {
WaitAck,
}
+impl I2cMode {
+ /// The save-state tag. Fixed numbers, not `as u8`, so reordering the enum
+ /// cannot silently change the format.
+ const fn to_byte(self) -> u8 {
+ match self {
+ Self::Idle => 0,
+ Self::ChipAddress => 1,
+ Self::Address => 2,
+ Self::Read => 3,
+ Self::Write => 4,
+ Self::SendAck => 5,
+ Self::WaitAck => 6,
+ }
+ }
+
+ const fn from_byte(b: u8) -> Option {
+ Some(match b {
+ 0 => Self::Idle,
+ 1 => Self::ChipAddress,
+ 2 => Self::Address,
+ 3 => Self::Read,
+ 4 => Self::Write,
+ 5 => Self::SendAck,
+ 6 => Self::WaitAck,
+ _ => return None,
+ })
+ }
+}
+
impl Eeprom {
fn new(bytes: usize, is_x24c01: bool) -> Self {
Self {
@@ -207,9 +278,81 @@ impl Eeprom {
data: 0,
counter: 0,
output: 1,
+ keeps_word_address: !is_x24c01,
}
}
+ /// Bytes [`Self::save_lines`] writes: the two sampled line levels, both
+ /// protocol modes, and the five transaction registers.
+ const LINES_LEN: usize = 9;
+
+ /// The chip's PROTOCOL state, for a save state (v3.2.0, version 3). The
+ /// contents alone are not enough: a state taken in the middle of a
+ /// transaction must resume it, and since 24C02 random reads keep the word
+ /// address across a START (`keeps_word_address`), `addr` is live between
+ /// transactions too. Before version 3 a restore left all of this at its
+ /// live values, so rollback and run-ahead diverged from a straight run
+ /// (`CodeRabbit` on #604). This is RustyNES's own serialisation of the
+ /// fields above; nothing in it comes from a reference emulator.
+ fn save_lines(&self, out: &mut Vec) {
+ out.extend_from_slice(&[
+ self.last_scl,
+ self.last_sda,
+ self.mode.to_byte(),
+ self.next_mode.to_byte(),
+ self.chip_addr,
+ self.addr,
+ self.data,
+ self.counter,
+ self.output,
+ ]);
+ }
+
+ /// Check a [`Self::save_lines`] record without changing anything, so a
+ /// caller can validate every part of a state before restoring any of it.
+ ///
+ /// It refuses what THIS chip cannot reach as well as what no chip can: the
+ /// read paths index `mem` by `addr` unmasked, so an X24C01 (128 bytes)
+ /// restored with a word address of `$80` or more, or in the 24C02-only
+ /// `ChipAddress` mode, would index past its memory and panic (the
+ /// commit-time security review of 10d0b027).
+ fn check_lines(&self, b: &[u8]) -> Result<(), MapperError> {
+ let bad = |what: &str| Err(MapperError::Invalid(format!("EEPROM state: {what}")));
+ if b.len() != Self::LINES_LEN {
+ return bad("wrong length");
+ }
+ if b[0] > 1 || b[1] > 1 || b[8] > 1 {
+ return bad("a line level above 1");
+ }
+ if I2cMode::from_byte(b[2]).is_none() || I2cMode::from_byte(b[3]).is_none() {
+ return bad("unknown protocol mode");
+ }
+ if b[7] > 8 {
+ return bad("bit counter above 8");
+ }
+ if usize::from(b[5]) >= self.mem.len() {
+ return bad("word address past the chip");
+ }
+ let chip_address = I2cMode::ChipAddress.to_byte();
+ if self.is_x24c01 && (b[2] == chip_address || b[3] == chip_address) {
+ return bad("a device-select phase on an X24C01, which has none");
+ }
+ Ok(())
+ }
+
+ /// Restore a record [`Self::check_lines`] accepted.
+ fn load_lines(&mut self, b: &[u8]) {
+ self.last_scl = b[0];
+ self.last_sda = b[1];
+ self.mode = I2cMode::from_byte(b[2]).unwrap_or(I2cMode::Idle);
+ self.next_mode = I2cMode::from_byte(b[3]).unwrap_or(I2cMode::Idle);
+ self.chip_addr = b[4];
+ self.addr = b[5];
+ self.data = b[6];
+ self.counter = b[7];
+ self.output = b[8];
+ }
+
fn addr_mask(&self) -> u8 {
if self.is_x24c01 { 0x7F } else { 0xFF }
}
@@ -257,7 +400,9 @@ impl Eeprom {
} else {
I2cMode::ChipAddress
};
- self.addr = 0;
+ if !self.keeps_word_address {
+ self.addr = 0;
+ }
self.counter = 0;
self.output = 1;
} else if self.last_scl != 0 && scl != 0 && sda > self.last_sda {
@@ -322,7 +467,7 @@ impl Eeprom {
} else {
// 24C02: sequential read continues to the next byte.
self.next_mode = I2cMode::Read;
- self.data = self.mem[self.addr as usize];
+ self.data = self.mem[(self.addr & self.addr_mask()) as usize];
}
}
}
@@ -340,7 +485,7 @@ impl Eeprom {
self.output = 1;
if self.chip_addr & 0x01 != 0 {
self.next_mode = I2cMode::Read;
- self.data = self.mem[self.addr as usize];
+ self.data = self.mem[(self.addr & self.addr_mask()) as usize];
} else {
self.next_mode = I2cMode::Address;
}
@@ -413,7 +558,7 @@ impl Eeprom {
}
}
-/// Bandai FCG mapper (iNES mappers 16 + 159).
+/// Bandai FCG mapper (iNES mappers 16, 153, 157 and 159).
pub struct BandaiFcg {
prg_rom: Box<[u8]>,
chr: Box<[u8]>,
@@ -442,6 +587,17 @@ pub struct BandaiFcg {
wram_enabled: bool,
/// Mapper 153: 8 KiB of battery-backed WRAM; empty on the other variants.
wram: Box<[u8]>,
+
+ /// Mapper 157: the game cartridge's own 128-byte X24C01, present when the
+ /// NES 2.0 header declares PRG-NVRAM (*Battle Rush*). It shares SDA with
+ /// the main unit's 24C02 but has its own clock, `$8000-$8003` bit 3.
+ ext_eeprom: Option,
+ /// Mapper 157: the last level written to the external EEPROM's SCL
+ /// (`$8000-$8003` bit 3).
+ ext_scl: bool,
+ /// Mapper 157: the barcode scanner's serial output (bit 3 of a
+ /// `$6000-$7FFF` read). Idle on every other variant.
+ barcode: BarcodeReader,
}
impl BandaiFcg {
@@ -503,9 +659,90 @@ impl BandaiFcg {
} else {
Box::new([])
},
+ ext_eeprom: None,
+ ext_scl: false,
+ barcode: BarcodeReader::default(),
})
}
+ /// Construct mapper 157, the Datach Joint ROM System
+ /// (`nesdev_wiki/INES_Mapper_157.xhtml`).
+ ///
+ /// `external_x24c01` adds the game cartridge's 128-byte X24C01, which the
+ /// page says the NES 2.0 header signals with PRG-NVRAM (byte 10 = `$10`)
+ /// and which only *Battle Rush: Build up Robot Tournament* carries.
+ ///
+ /// # Errors
+ ///
+ /// As [`Self::new`].
+ pub fn new_datach(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ external_x24c01: bool,
+ ) -> Result {
+ let mut m = Self::new(prg_rom, chr_rom, mirroring, FcgVariant::Datach)?;
+ if external_x24c01 {
+ m.ext_eeprom = Some(Eeprom::new(128, true));
+ }
+ Ok(m)
+ }
+
+ /// Mapper 157's save-state tail, after the fields every variant writes:
+ /// the external SCL level, the external X24C01's 128 bytes when the
+ /// cartridge has one (fixed by the header, so not flagged), then the
+ /// barcode reader ([`BarcodeReader::save`]). Added with the variant in
+ /// v3.2.0, so no earlier blob of this variant exists. The tail itself
+ /// needed no version bump; [`SAVE_STATE_VERSION`] is 3 for a different
+ /// reason, the EEPROM protocol state every variant with an EEPROM now
+ /// saves (an earlier draft of this comment said it "stays 2").
+ fn save_datach_tail(&self, out: &mut Vec) {
+ out.push(u8::from(self.ext_scl));
+ if let Some(ext) = self.ext_eeprom.as_ref() {
+ out.extend_from_slice(&ext.mem);
+ ext.save_lines(out);
+ }
+ self.barcode.save(out);
+ }
+
+ /// Restore [`Self::save_datach_tail`]. Validates the whole tail before
+ /// changing anything.
+ fn load_datach_tail(&mut self, tail: &[u8]) -> Result<(), MapperError> {
+ let ext_len = self
+ .ext_eeprom
+ .as_ref()
+ .map_or(0, |e| e.mem.len() + Eeprom::LINES_LEN);
+ let fixed = 1 + ext_len;
+ let short = || MapperError::Invalid(format!("Datach state tail of {} bytes", tail.len()));
+ if tail.len() < fixed {
+ return Err(short());
+ }
+ let mut reader = BarcodeReader::default();
+ let used = reader.load(&tail[fixed..]).ok_or_else(short)?;
+ if fixed + used != tail.len() {
+ return Err(short());
+ }
+ if let Some(ext) = self.ext_eeprom.as_ref() {
+ ext.check_lines(&tail[1 + ext.mem.len()..fixed])?;
+ }
+ self.ext_scl = tail[0] != 0;
+ if let Some(ext) = self.ext_eeprom.as_mut() {
+ let n = ext.mem.len();
+ ext.mem.copy_from_slice(&tail[1..=n]);
+ ext.load_lines(&tail[1 + n..fixed]);
+ }
+ self.barcode = reader;
+ Ok(())
+ }
+
+ /// The data line both EEPROMs drive back toward the CPU. "If there is
+ /// both an internal and an external EEPROM, the Data out from both
+ /// EEPROMs is ANDed (open drain circuit)" (`INES_Mapper_157`).
+ fn eeprom_data_out(&self) -> bool {
+ self.eeprom.as_ref().is_none_or(Eeprom::read_sda)
+ && self.ext_eeprom.as_ref().is_none_or(Eeprom::read_sda)
+ }
+
/// Mapper 153's outer bank as a 16 KiB bank offset (256 KiB = 16 banks).
const fn outer_base(&self) -> usize {
(self.outer as usize & 0x01) << 4
@@ -524,7 +761,9 @@ impl BandaiFcg {
// the outer PRG bank instead, so `chr_banks` is never written there
// and must not be consulted: routing through it mapped all eight
// 1 KiB windows onto the first 1 KiB.
- if self.variant == FcgVariant::Lz93d50Wram {
+ // Mapper 157 likewise: "8 KiB of unbanked CHR-RAM instead of
+ // bankswitched CHR-ROM", "No CHR banking is available".
+ if matches!(self.variant, FcgVariant::Lz93d50Wram | FcgVariant::Datach) {
return addr as usize & 0x1FFF;
}
let slot = (addr as usize / CHR_BANK_1K) & 0x07;
@@ -540,6 +779,19 @@ impl BandaiFcg {
// PRG bank and `$4-$7` do nothing.
0x0..=0x3 if self.variant == FcgVariant::Lz93d50Wram => self.outer = value & 0x01,
0x4..=0x7 if self.variant == FcgVariant::Lz93d50Wram => {}
+ // Mapper 157: bit 3 of `$8000-$8003` is CHR A13 OUT, wired to
+ // the external X24C01's SCL ("External EEPROM Clock"). The ASIC's
+ // PA12/PA13 inputs are grounded, so only `$0-$3` respond and
+ // `$4-$7` do nothing; no CHR bank is selected by any of them. The
+ // SDA the external chip sees is the shared line, `$800D` bit 6.
+ 0x0..=0x3 if self.variant == FcgVariant::Datach => {
+ self.ext_scl = value & 0x08 != 0;
+ let sda = self.eeprom_ctrl & 0x40 != 0;
+ if let Some(ext) = self.ext_eeprom.as_mut() {
+ ext.write_lines(self.ext_scl, sda);
+ }
+ }
+ 0x4..=0x7 if self.variant == FcgVariant::Datach => {}
0x0..=0x7 => self.chr_banks[(off & 0x07) as usize] = value,
0x8 => self.prg_bank = value & 0x0F,
0x9 => {
@@ -585,6 +837,11 @@ impl BandaiFcg {
let sda = (value & 0x40) != 0;
ee.write_lines(scl, sda);
}
+ // Mapper 157: bit 6 is "Both EEPROM's I2C SDA"; the external
+ // chip keeps the clock last written to `$8000-$8003`.
+ if let Some(ext) = self.ext_eeprom.as_mut() {
+ ext.write_lines(self.ext_scl, value & 0x40 != 0);
+ }
}
_ => {}
}
@@ -626,6 +883,14 @@ impl Mapper for BandaiFcg {
0
}
}
+ // Mapper 157 (mask `$E000`): bit 4 "Data out from I2C EEPROMs"
+ // (the two chips ANDed), bit 3 "Serial data out from Barcode
+ // scanner". The other bits are open bus on the board; like the
+ // mapper-16 read below they read 0 here, because the bus consults
+ // a partial driven-bit mask only for `$4020-$5FFF`.
+ 0x6000..=0x7FFF if self.variant == FcgVariant::Datach => {
+ (u8::from(self.eeprom_data_out()) << 4) | (u8::from(self.barcode.level()) << 3)
+ }
0x6000..=0x7FFF => {
// EEPROM read appears in bit 4 (LZ93D50). Otherwise open bus
// (the bus's open-bus latch handles unmapped reads, but the
@@ -691,6 +956,18 @@ impl Mapper for BandaiFcg {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -710,6 +987,9 @@ impl Mapper for BandaiFcg {
}
fn notify_cpu_cycle(&mut self) {
+ // Mapper 157: the scanner's stream is timed in CPU cycles (no wall
+ // clock), so it advances here, before the IRQ's early return.
+ self.barcode.tick();
if !self.irq_enabled {
return;
}
@@ -726,12 +1006,29 @@ impl Mapper for BandaiFcg {
self.irq_pending
}
+ fn has_barcode_reader(&self) -> bool {
+ self.variant == FcgVariant::Datach
+ }
+
+ fn scan_barcode(&mut self, code: &Barcode) -> bool {
+ if self.variant != FcgVariant::Datach {
+ return false;
+ }
+ self.barcode.scan(code);
+ true
+ }
+
+ fn barcode_scan_in_progress(&self) -> bool {
+ self.barcode.is_scanning()
+ }
+
fn current_mirroring(&self) -> Mirroring {
self.mirroring
}
fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
let id = match self.variant {
+ FcgVariant::Datach => 157,
FcgVariant::Lz93d50_24c01 => 159,
FcgVariant::Lz93d50Wram => 153,
_ => 16,
@@ -767,7 +1064,10 @@ impl Mapper for BandaiFcg {
}
fn save_state(&self) -> Vec {
- let ee_len = self.eeprom.as_ref().map_or(0, |e| e.mem.len());
+ let ee_len = self
+ .eeprom
+ .as_ref()
+ .map_or(0, |e| e.mem.len() + Eeprom::LINES_LEN);
let mut out = Vec::with_capacity(
18 + self.vram.len() + ee_len + if self.chr_is_ram { self.chr.len() } else { 0 },
);
@@ -780,9 +1080,10 @@ impl Mapper for BandaiFcg {
out.push(u8::from(self.irq_enabled));
out.push(u8::from(self.irq_pending));
out.push(self.eeprom_ctrl);
- // EEPROM contents (if any).
+ // EEPROM contents and protocol state (if any).
if let Some(ee) = self.eeprom.as_ref() {
out.extend_from_slice(&ee.mem);
+ ee.save_lines(&mut out);
}
out.extend_from_slice(&self.vram);
if self.chr_is_ram {
@@ -791,11 +1092,17 @@ impl Mapper for BandaiFcg {
out.push(self.outer);
out.push(u8::from(self.wram_enabled));
out.extend_from_slice(&self.wram);
+ if self.variant == FcgVariant::Datach {
+ self.save_datach_tail(&mut out);
+ }
out
}
fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
- let ee_len = self.eeprom.as_ref().map_or(0, |e| e.mem.len());
+ let ee_len = self
+ .eeprom
+ .as_ref()
+ .map_or(0, |e| e.mem.len() + Eeprom::LINES_LEN);
let need_chr = if self.chr_is_ram { self.chr.len() } else { 0 };
// Only the current version is read (v2.9.8, ADR 0042); a v1 blob,
// which carried no mapper-153 tail, used to load on boards without one.
@@ -805,15 +1112,30 @@ impl Mapper for BandaiFcg {
Some(&v) => return Err(MapperError::UnsupportedVersion(v)),
None => core_len,
};
+ // Mapper 157 appends a tail whose length depends on the scan in
+ // flight, so it is checked (and restored) separately, after the
+ // fixed part.
+ let (data, datach_tail) = if self.variant == FcgVariant::Datach {
+ if data.len() < expected {
+ return Err(MapperError::WrongLength {
+ expected,
+ got: data.len(),
+ });
+ }
+ data.split_at(expected)
+ } else {
+ (data, &[][..])
+ };
if data.len() != expected {
return Err(MapperError::WrongLength {
expected,
got: data.len(),
});
}
- self.prg_bank = data[1];
- self.chr_banks.copy_from_slice(&data[2..10]);
- self.mirroring = match data[10] {
+ // Validate everything before changing anything (CodeRabbit on #607:
+ // an unknown mirroring byte used to return Err after the Datach tail,
+ // the PRG bank and the CHR banks had already been restored).
+ let mirroring = match data[10] {
0 => Mirroring::Horizontal,
1 => Mirroring::Vertical,
2 => Mirroring::SingleScreenA,
@@ -821,6 +1143,16 @@ impl Mapper for BandaiFcg {
4 => Mirroring::FourScreen,
other => return Err(MapperError::Invalid(format!("mirroring {other}"))),
};
+ if let Some(ee) = self.eeprom.as_ref() {
+ let at = 18 + ee.mem.len();
+ ee.check_lines(&data[at..at + Eeprom::LINES_LEN])?;
+ }
+ if self.variant == FcgVariant::Datach {
+ self.load_datach_tail(datach_tail)?;
+ }
+ self.prg_bank = data[1];
+ self.chr_banks.copy_from_slice(&data[2..10]);
+ self.mirroring = mirroring;
self.irq_latch = u16::from_le_bytes([data[11], data[12]]);
self.irq_counter = u16::from_le_bytes([data[13], data[14]]);
self.irq_enabled = data[15] != 0;
@@ -837,8 +1169,10 @@ impl Mapper for BandaiFcg {
self.irq_pending = pending != 0;
self.eeprom_ctrl = ctrl;
if let Some(ee) = self.eeprom.as_mut() {
- ee.mem.copy_from_slice(&data[cursor..cursor + ee.mem.len()]);
- cursor += ee.mem.len();
+ let n = ee.mem.len();
+ ee.mem.copy_from_slice(&data[cursor..cursor + n]);
+ ee.load_lines(&data[cursor + n..cursor + n + Eeprom::LINES_LEN]);
+ cursor += n + Eeprom::LINES_LEN;
}
self.vram
.copy_from_slice(&data[cursor..cursor + self.vram.len()]);
@@ -1088,6 +1422,101 @@ mod tests {
}
}
+ /// A save state taken MID-TRANSACTION must restore the protocol, not just
+ /// the EEPROM's bytes (`CodeRabbit` on #604). Rollback, run-ahead and rewind
+ /// all restore at arbitrary cycles, and a game polling its save chip spans
+ /// many frames; before v3.2.0's version 3 the blob carried only `mem` and
+ /// `$800D`, so the restored board was Idle where the original was halfway
+ /// through a read, and the two diverged.
+ #[test]
+ fn a_state_saved_mid_read_restores_the_transaction() {
+ let variant = FcgVariant::Lz93d50_24c01;
+ let mut a =
+ BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap();
+ a.eeprom.as_mut().unwrap().mem[0x05] = 0xAB;
+ {
+ let mut d = I2cDriver { m: &mut a };
+ d.start();
+ d.send_addr_rw(0x05, true);
+ assert!(d.read_ack(), "the X24C01 acknowledges its address");
+ }
+ let blob = a.save_state();
+
+ let mut b =
+ BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap();
+ b.eeprom.as_mut().unwrap().mem[0x05] = 0x00; // overwritten by the load
+ b.load_state(&blob).unwrap();
+
+ let from_a = I2cDriver { m: &mut a }.recv_data_lsb_first();
+ let from_b = I2cDriver { m: &mut b }.recv_data_lsb_first();
+ assert_eq!(from_a, 0xAB);
+ assert_eq!(from_b, from_a, "the restored board continues the same read");
+ assert_eq!(b.save_state(), a.save_state());
+ }
+
+ /// The protocol bytes are validated before anything is restored: an
+ /// unknown mode tag, a counter past 8 or a line above 1 is refused, and the
+ /// board keeps its state. So is an unknown mirroring byte, which used to
+ /// return Err only after the banks had been restored (`CodeRabbit` on #607).
+ /// A state the X24C01 could never be in is refused: a word address past
+ /// its 128 bytes, or the 24C02-only device-select phase. Either would make
+ /// the next clocked read index past `mem` and panic.
+ #[test]
+ fn an_x24c01_state_it_cannot_reach_is_refused() {
+ let variant = FcgVariant::Lz93d50_24c01;
+ let m = BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap();
+ let good = m.save_state();
+ let lines = 18 + m.eeprom.as_ref().unwrap().mem.len();
+ let mut other =
+ BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap();
+ let before = other.save_state();
+ for (at, value, what) in [
+ (lines + 5, 0x80, "word address $80"),
+ (
+ lines + 2,
+ I2cMode::ChipAddress.to_byte(),
+ "ChipAddress mode",
+ ),
+ (
+ lines + 3,
+ I2cMode::ChipAddress.to_byte(),
+ "ChipAddress next mode",
+ ),
+ ] {
+ let mut bad = good.clone();
+ bad[at] = value;
+ assert!(other.load_state(&bad).is_err(), "{what} refused");
+ assert_eq!(other.save_state(), before, "{what}: nothing restored");
+ }
+ }
+
+ #[test]
+ fn a_corrupt_state_is_refused_before_anything_changes() {
+ let variant = FcgVariant::Lz93d50_24c02;
+ let mut m =
+ BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap();
+ m.cpu_write(0x8008, 3);
+ let good = m.save_state();
+ let lines = 18 + m.eeprom.as_ref().unwrap().mem.len();
+ let mut other =
+ BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, variant).unwrap();
+ other.cpu_write(0x8008, 1);
+ let before = other.save_state();
+ for (at, value, what) in [
+ (lines + 2, 7, "mode tag"),
+ (lines + 7, 9, "bit counter"),
+ (lines, 2, "SCL level"),
+ (10, 9, "mirroring"),
+ ] {
+ let mut bad = good.clone();
+ bad[at] = value;
+ assert!(other.load_state(&bad).is_err(), "{what} {value} refused");
+ assert_eq!(other.save_state(), before, "{what}: nothing restored");
+ }
+ other.load_state(&good).unwrap();
+ assert_eq!(other.save_state(), good);
+ }
+
#[test]
fn x24c01_write_then_read_round_trips() {
let mut m = BandaiFcg::new(
@@ -1306,4 +1735,384 @@ mod tests {
Err(MapperError::UnsupportedVersion(1))
));
}
+
+ // ---- Mapper 157, the Datach Joint ROM System (`INES_Mapper_157`) ------
+
+ /// A NES 2.0 image for mapper 157: `banks_16k` PRG banks numbered in
+ /// their first byte, no CHR-ROM (8 KiB CHR-RAM), and NES 2.0 byte 10 set
+ /// to `0x10` when the game cartridge carries the external X24C01 (the
+ /// page's *Battle Rush* row) or `0x00` when it does not.
+ fn m157_rom(banks_16k: usize, external_eeprom: bool) -> Vec {
+ let mut rom = vec![0u8; 16];
+ rom[0..4].copy_from_slice(b"NES\x1A");
+ rom[4] = banks_16k as u8;
+ rom[5] = 0;
+ rom[6] = 0xD0; // mapper low nibble $D (157 = $9D)
+ rom[7] = 0x98; // mapper high nibble $9, NES 2.0 identifier
+ rom[10] = if external_eeprom { 0x10 } else { 0x00 };
+ rom[11] = 0x07; // 8 KiB CHR-RAM
+ rom.extend_from_slice(&synth_prg(banks_16k));
+ rom
+ }
+
+ fn m157(external_eeprom: bool) -> Box {
+ crate::parse(&m157_rom(16, external_eeprom))
+ .expect("mapper 157 must construct")
+ .1
+ }
+
+ /// Banks: "CPU $8000-$BFFF: 16 KiB switchable PRG ROM bank", "CPU
+ /// $C000-$FFFF: 16 KiB PRG ROM bank, fixed to the last bank", and the
+ /// `$8008` register that "function[s] the same way as on INES Mapper 016,
+ /// submapper 5" (mask `$800F`, so `$FFF8` decodes as `$8008`).
+ #[test]
+ fn m157_prg_bank_at_8008_and_the_last_bank_fixed() {
+ let mut m = m157(false);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 15);
+ m.cpu_write(0x8008, 5);
+ assert_eq!(m.cpu_read(0x8000), 5);
+ assert_eq!(m.cpu_read(0xBFFF & 0xC000), 5);
+ assert_eq!(m.cpu_read(0xC000), 15);
+ m.cpu_write(0xFFF8, 9);
+ assert_eq!(m.cpu_read(0x8000), 9, "mask $800F: $FFF8 is $8008");
+ // The LZ93D50's register window is $8000-$FFFF only.
+ m.cpu_write(0x6008, 2);
+ assert_eq!(m.cpu_read(0x8000), 9, "$6008 is not a register here");
+ }
+
+ /// "PPU $0000-$1FFF: 8 KiB unbanked CHR-RAM" and "No CHR banking is
+ /// available": writes to `$8000-$8007` must not move any CHR window, and
+ /// the eight 1 KiB windows must not alias.
+ #[test]
+ fn m157_chr_is_8k_of_unbanked_ram() {
+ let mut m = m157(false);
+ for a in 0x8000..=0x8007u16 {
+ m.cpu_write(a, 0x03);
+ }
+ for slot in 0..8u16 {
+ m.ppu_write(slot * 0x400 + 0x055, 0xB0 | slot as u8);
+ }
+ for slot in 0..8u16 {
+ assert_eq!(m.ppu_read(slot * 0x400 + 0x055), 0xB0 | slot as u8);
+ }
+ }
+
+ /// `$8009`: "the same way as on INES Mapper 016" (0 V, 1 H, 2 1scA, 3 1scB).
+ #[test]
+ fn m157_mirroring_at_8009() {
+ let mut m = m157(false);
+ for (v, want) in [
+ (0u8, Mirroring::Vertical),
+ (1, Mirroring::Horizontal),
+ (2, Mirroring::SingleScreenA),
+ (3, Mirroring::SingleScreenB),
+ ] {
+ m.cpu_write(0x8009, v);
+ assert_eq!(m.current_mirroring(), want, "$8009 = {v}");
+ }
+ }
+
+ /// `$800A-$800C`: the LZ93D50's latched 16-bit M2 down-counter.
+ #[test]
+ fn m157_irq_is_the_latched_lz93d50_counter() {
+ let mut m = m157(false);
+ m.cpu_write(0x800B, 0x02);
+ m.cpu_write(0x800C, 0x00);
+ m.cpu_write(0x800A, 0x01);
+ m.notify_cpu_cycle();
+ m.notify_cpu_cycle();
+ assert!(!m.irq_pending());
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending(), "latch 2 reaches zero on the third M2");
+ m.cpu_write(0x800A, 0x00);
+ assert!(!m.irq_pending(), "a $800A write acknowledges");
+ }
+
+ /// The main unit's 24C02 is the battery save, 256 bytes, with or without
+ /// the cartridge's X24C01.
+ #[test]
+ fn m157_battery_save_is_the_main_units_24c02() {
+ assert_eq!(m157(false).sram().len(), 256);
+ assert_eq!(m157(true).sram().len(), 256);
+ }
+
+ /// `$6000-$7FFF` read: bit 4 is the EEPROMs' data out (released = high
+ /// when idle), bit 3 the barcode scanner, the rest open bus.
+ #[test]
+ fn m157_6000_drives_bits_4_and_3() {
+ let mut m = m157(true);
+ assert!(!m.cpu_read_unmapped(0x6000));
+ let v = m.cpu_read(0x7FFF);
+ assert_eq!(v & 0x10, 0x10, "idle EEPROMs release SDA");
+ assert_eq!(v & 0x08, 0, "an empty barcode slot reads dark (0)");
+ assert_eq!(v & !0x18, 0, "undriven bits read 0 here");
+ }
+
+ fn datach(external: bool) -> BandaiFcg {
+ BandaiFcg::new_datach(synth_prg(16), Box::new([]), Mirroring::Vertical, external).unwrap()
+ }
+
+ /// Drive the Datach's two I2C buses. The internal 24C02 is clocked by
+ /// `$800D` bit 5, the external X24C01 by `$8000-$8003` bit 3; both share
+ /// SDA, `$800D` bit 6 (`INES_Mapper_157`, "EEPROM Control" and "External
+ /// EEPROM Clock").
+ struct DatachI2c<'a> {
+ m: &'a mut BandaiFcg,
+ external: bool,
+ }
+
+ impl DatachI2c<'_> {
+ fn lines(&mut self, scl: bool, sda: bool) {
+ if self.external {
+ // Set SDA with the internal clock held low, then the external
+ // clock through CHR A13 OUT.
+ self.m.cpu_write(0x800D, u8::from(sda) << 6);
+ self.m.cpu_write(0x8000, u8::from(scl) << 3);
+ } else {
+ self.m
+ .cpu_write(0x800D, (u8::from(sda) << 6) | (u8::from(scl) << 5));
+ }
+ }
+ fn start(&mut self) {
+ self.lines(false, true);
+ self.lines(true, true);
+ self.lines(true, false);
+ self.lines(false, false);
+ }
+ fn stop(&mut self) {
+ self.lines(false, false);
+ self.lines(true, false);
+ self.lines(true, true);
+ }
+ fn send_bit(&mut self, bit: bool) {
+ self.lines(false, bit);
+ self.lines(true, bit);
+ self.lines(false, bit);
+ }
+ /// Release SDA, clock once, and sample `$6000` bit 4 while SCL is high.
+ fn recv_bit(&mut self) -> bool {
+ self.lines(false, true);
+ self.lines(true, true);
+ let bit = self.m.cpu_read(0x6000) & 0x10 != 0;
+ self.lines(false, true);
+ bit
+ }
+ fn send_msb_first(&mut self, byte: u8) -> bool {
+ for i in (0..8).rev() {
+ self.send_bit(byte >> i & 1 != 0);
+ }
+ !self.recv_bit()
+ }
+ fn recv_msb_first(&mut self) -> u8 {
+ (0..8).fold(0u8, |acc, _| acc << 1 | u8::from(self.recv_bit()))
+ }
+ }
+
+ /// The main unit's 24C02 at device address 000b ("the highest device
+ /// type bits being 1010b"): a byte write, then a **random read** (dummy
+ /// write of the word address, repeated START, read select), which must
+ /// begin at the address just written. Before the v3.2.0
+ /// `keeps_word_address` change a repeated START cleared the address and
+ /// this read returned byte `$00`'s contents.
+ #[test]
+ fn m157_internal_24c02_random_read_starts_at_the_written_address() {
+ let mut m = datach(false);
+ {
+ let mut d = DatachI2c {
+ m: &mut m,
+ external: false,
+ };
+ d.start();
+ assert!(d.send_msb_first(0xA0), "device select acked");
+ assert!(d.send_msb_first(0x37), "word address acked");
+ assert!(d.send_msb_first(0xC4), "data acked");
+ d.stop();
+ d.start();
+ assert!(d.send_msb_first(0xA0));
+ assert!(d.send_msb_first(0x37));
+ d.start(); // repeated START
+ assert!(d.send_msb_first(0xA1), "read select acked");
+ assert_eq!(d.recv_msb_first(), 0xC4, "random read of $37");
+ d.stop();
+ }
+ assert_eq!(m.sram()[0x37], 0xC4);
+ assert_eq!(m.sram()[0x00], 0xFF, "byte $00 untouched");
+ }
+
+ /// v3.2.0: the same random read on mapper 16's LZ93D50 + 24C02 board.
+ /// Keeping the word address across a START is a property of the 24C02,
+ /// not of the Datach, so it holds on every board that carries one.
+ #[test]
+ fn m016_24c02_random_read_starts_at_the_written_address() {
+ let mut m = BandaiFcg::new(
+ synth_prg(8),
+ synth_chr(16),
+ Mirroring::Vertical,
+ FcgVariant::Lz93d50_24c02,
+ )
+ .unwrap();
+ {
+ let mut d = DatachI2c {
+ m: &mut m,
+ external: false,
+ };
+ d.start();
+ assert!(d.send_msb_first(0xA0), "device select acked");
+ assert!(d.send_msb_first(0x5A), "word address acked");
+ assert!(d.send_msb_first(0x3C), "data acked");
+ d.stop();
+ d.start();
+ assert!(d.send_msb_first(0xA0));
+ assert!(d.send_msb_first(0x5A));
+ d.start(); // repeated START
+ assert!(d.send_msb_first(0xA1), "read select acked");
+ assert_eq!(d.recv_msb_first(), 0x3C, "random read of $5A");
+ d.stop();
+ }
+ assert_eq!(m.sram()[0x5A], 0x3C);
+ }
+
+ /// The cartridge's X24C01 answers only on its own clock, `$8000-$8003`
+ /// bit 3, and (Bandai's convention, modelled by the existing X24C01
+ /// state machine) shifts LSB-first. The 24C02 never sees that clock, so
+ /// its contents are untouched, and `$8004-$8007` clock nothing.
+ #[test]
+ fn m157_external_x24c01_is_clocked_by_8000_bit_3_only() {
+ let mut m = datach(true);
+ {
+ let mut d = DatachI2c {
+ m: &mut m,
+ external: true,
+ };
+ d.start();
+ // X24C01: 7 address bits LSB-first, then R/W = 0.
+ for i in 0..7 {
+ d.send_bit(0x15 >> i & 1 != 0);
+ }
+ d.send_bit(false);
+ assert!(!d.recv_bit(), "address acked");
+ for i in 0..8 {
+ d.send_bit(0x6B >> i & 1 != 0);
+ }
+ assert!(!d.recv_bit(), "data acked");
+ d.stop();
+ }
+ assert_eq!(m.ext_eeprom.as_ref().unwrap().mem[0x15], 0x6B);
+ assert!(m.sram().iter().all(|&b| b == 0xFF), "24C02 untouched");
+ // `$8004` is not the external clock: toggling it with SDA moving
+ // does not form a START on the X24C01.
+ m.cpu_write(0x8000, 0x00); // external SCL low
+ m.cpu_write(0x800D, 0x40); // SDA high
+ let before = m.ext_eeprom.as_ref().unwrap().mode;
+ m.cpu_write(0x8004, 0x08); // would raise SCL if it were decoded
+ m.cpu_write(0x800D, 0x00); // SDA falls: a START only if SCL were high
+ assert_eq!(m.ext_eeprom.as_ref().unwrap().mode, before);
+ assert!(!m.ext_scl);
+ // The same sequence through `$8003` is a START.
+ m.cpu_write(0x800D, 0x40);
+ m.cpu_write(0x8003, 0x08);
+ m.cpu_write(0x800D, 0x00);
+ assert_eq!(m.ext_eeprom.as_ref().unwrap().mode, I2cMode::Address);
+ }
+
+ /// "the Data out from both EEPROMs is ANDed": either chip pulling SDA
+ /// low pulls bit 4 low.
+ #[test]
+ fn m157_eeprom_outputs_are_wired_and() {
+ let mut m = datach(true);
+ assert_eq!(m.cpu_read(0x6000) & 0x10, 0x10);
+ m.ext_eeprom.as_mut().unwrap().output = 0;
+ assert_eq!(m.cpu_read(0x6000) & 0x10, 0, "external low");
+ m.ext_eeprom.as_mut().unwrap().output = 1;
+ m.eeprom.as_mut().unwrap().output = 0;
+ assert_eq!(m.cpu_read(0x6000) & 0x10, 0, "internal low");
+ }
+
+ /// The scanner's stream on bit 3: dark (0) idle, then the card's light
+ /// margin, the EAN-8 symbol 96385074 module by module (bar = 0, space =
+ /// 1), the light margin, and dark again.
+ #[test]
+ fn m157_scan_presents_the_symbol_on_bit_3() {
+ use crate::datach_barcode::{MODULE_CYCLES, QUIET_MODULES};
+ let mut m = datach(false);
+ assert!(m.has_barcode_reader());
+ let code = Barcode::parse("96385074").unwrap();
+ assert!(m.scan_barcode(&code));
+ let mut seen = alloc::string::String::new();
+ let total = (67 + 2 * QUIET_MODULES) * MODULE_CYCLES;
+ for cycle in 0..total {
+ if cycle % MODULE_CYCLES == MODULE_CYCLES / 2 {
+ seen.push(if m.cpu_read(0x6000) & 0x08 != 0 {
+ '1'
+ } else {
+ '0'
+ });
+ }
+ m.notify_cpu_cycle();
+ }
+ let margin = "1".repeat(QUIET_MODULES as usize);
+ // The published EAN-8 modules for 96385074, inverted (bar = 0).
+ let symbol: alloc::string::String = concat!(
+ "101", "0001011", "0101111", "0111101", "0110111", "01010", "1001110", "1110010",
+ "1000100", "1011100", "101",
+ )
+ .chars()
+ .map(|c| if c == '1' { '0' } else { '1' })
+ .collect();
+ assert_eq!(seen, alloc::format!("{margin}{symbol}{margin}"));
+ assert!(!m.barcode_scan_in_progress());
+ assert_eq!(m.cpu_read(0x6000) & 0x08, 0, "dark again when done");
+ }
+
+ #[test]
+ fn only_the_datach_has_a_barcode_reader() {
+ let code = Barcode::parse("96385074").unwrap();
+ for v in [
+ FcgVariant::Both,
+ FcgVariant::Fcg,
+ FcgVariant::Lz93d50_24c02,
+ FcgVariant::Lz93d50_24c01,
+ ] {
+ let mut m =
+ BandaiFcg::new(synth_prg(8), synth_chr(16), Mirroring::Vertical, v).unwrap();
+ assert!(!m.has_barcode_reader(), "{v:?}");
+ assert!(!m.scan_barcode(&code), "{v:?}");
+ }
+ }
+
+ /// A save state taken mid-scan restores mid-scan: the remaining stream,
+ /// the external EEPROM and its clock all come back, and a truncated or
+ /// padded tail is refused.
+ #[test]
+ fn m157_state_round_trips_mid_scan() {
+ let mut a = datach(true);
+ a.cpu_write(0x8008, 3);
+ a.cpu_write(0x8001, 0x08);
+ a.ext_eeprom.as_mut().unwrap().mem[5] = 0x99;
+ a.eeprom.as_mut().unwrap().mem[6] = 0x77;
+ a.scan_barcode(&Barcode::parse("4006381333931").unwrap());
+ for _ in 0..200_000 {
+ a.notify_cpu_cycle();
+ }
+ let blob = a.save_state();
+ let mut b = datach(true);
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert!(b.barcode_scan_in_progress());
+ assert!(b.ext_scl);
+ assert_eq!(b.ext_eeprom.as_ref().unwrap().mem[5], 0x99);
+ assert_eq!(b.sram()[6], 0x77);
+ for _ in 0..200_000 {
+ assert_eq!(a.cpu_read(0x6000), b.cpu_read(0x6000));
+ a.notify_cpu_cycle();
+ b.notify_cpu_cycle();
+ }
+ assert!(b.load_state(&blob[..blob.len() - 1]).is_err());
+ let mut long = blob.clone();
+ long.push(0);
+ assert!(b.load_state(&long).is_err());
+ // A board without the external chip has a shorter tail.
+ assert!(datach(false).load_state(&blob).is_err());
+ }
}
diff --git a/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs b/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs
index 8359f7e0e..80a7068dd 100644
--- a/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs
+++ b/crates/rustynes-mappers/src/m018_jaleco_ss88006.rs
@@ -340,6 +340,18 @@ impl Mapper for JalecoSs88006 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m019_namco163.rs b/crates/rustynes-mappers/src/m019_namco163.rs
index ed156393a..42f5b6995 100644
--- a/crates/rustynes-mappers/src/m019_namco163.rs
+++ b/crates/rustynes-mappers/src/m019_namco163.rs
@@ -768,6 +768,21 @@ impl Mapper for Namco163 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => match self.chr_target(addr) {
+ Ok(off) => self.chr_rom[off % self.chr_rom.len()],
+ Err(ciram) => self.vram[ciram],
+ },
+ 0x2000..=0x3EFF => match self.nt_target(addr) {
+ Ok(off) => self.chr_rom[off % self.chr_rom.len()],
+ Err(ciram) => self.vram[ciram],
+ },
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1138,6 +1153,34 @@ mod tests {
}
}
+ /// The mix sums only the `n` ACTIVE channels. With one channel enabled,
+ /// channel 7's register slot (`$70-$77`) holds a loud, non-silent setup
+ /// -- on hardware those bytes are often wavetable data -- and must not
+ /// reach the output. Regression for a v3.2.0 import that cached a running
+ /// sum over all eight slots, so any game using fewer than eight channels
+ /// heard the inactive slots too.
+ #[test]
+ #[cfg(feature = "mapper-audio")]
+ fn namco163_mix_ignores_inactive_channel_slots() {
+ let mut m = namco163_for_audio();
+ // Wavetable byte 0: low nibble 15 -> (15 - 8) = +7 per volume step.
+ n163_write_ram(&mut m, 0x00, false, 0x0F);
+ // Channel 8 ($78): length 4, wave address 0, phase 0; $7F = C=0
+ // (one channel) and volume 15.
+ n163_write_ram(&mut m, 0x7C, false, 0xFC);
+ n163_write_ram(&mut m, 0x7E, false, 0x00);
+ n163_write_ram(&mut m, 0x7F, false, 0x0F);
+ // Channel 7 ($70), inactive with C=0: the same loud setup.
+ n163_write_ram(&mut m, 0x74, false, 0xFC);
+ n163_write_ram(&mut m, 0x76, false, 0x00);
+ n163_write_ram(&mut m, 0x77, false, 0x0F);
+ assert_eq!(m.audio.channel_count(), 1);
+ assert_eq!(m.audio.channel_output(0), 105);
+ assert_eq!(m.audio.channel_output(1), 105);
+ // Only channel 8 is mixed: 105 / 1 * NAMCO163_MIX_SCALE.
+ assert_eq!(i32::from(m.audio.mix()), 105 * NAMCO163_MIX_SCALE);
+ }
+
#[test]
#[cfg(feature = "mapper-audio")]
fn namco163_channel_count_selection() {
diff --git a/crates/rustynes-mappers/src/m021_vrc4.rs b/crates/rustynes-mappers/src/m021_vrc4.rs
index 4f4ad30f0..5c8729902 100644
--- a/crates/rustynes-mappers/src/m021_vrc4.rs
+++ b/crates/rustynes-mappers/src/m021_vrc4.rs
@@ -306,10 +306,13 @@ impl Mapper for Vrc4 {
}
(false, true) => {
// Control: bit 0 = enable_after_ack, bit 1 = enable now,
- // bit 2 = mode (1 = scanline mode).
+ // bit 2 = mode M. `nesdev_wiki/VRC_IRQ.xhtml`: "IRQ Mode
+ // (1 = cycle mode, 0 = scanline mode)". Until v3.2.0 this
+ // read the bit the other way round (VRC6 / VRC7 had it
+ // right), so a scanline-mode split ran every CPU cycle.
self.irq_enable_after_ack = (value & 0x01) != 0;
self.irq_enabled = (value & 0x02) != 0;
- self.irq_mode_scanline = (value & 0x04) != 0;
+ self.irq_mode_scanline = (value & 0x04) == 0;
self.irq_pending = false;
if self.irq_enabled {
self.irq_counter = self.irq_latch;
@@ -338,6 +341,18 @@ impl Mapper for Vrc4 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -490,6 +505,13 @@ impl Mapper for Vrc4 {
}
impl Vrc4 {
+ /// The 1 KiB CHR bank register for PPU slot `slot` (`0..8`, one per
+ /// 1 KiB of `$0000-$1FFF`). For boards that wrap the VRC4 and intercept
+ /// particular bank numbers (mapper 252's CHR-RAM pages, v3.2.0).
+ pub(crate) fn chr_reg(&self, slot: usize) -> u8 {
+ self.chr[slot & 7]
+ }
+
/// Bytes the v2 tail adds: the 8 KiB PRG-RAM, plus the 8 KiB CHR-RAM
/// when the cartridge has no CHR-ROM. Derived from the loaded ROM, so a
/// save and its load (same ROM, checked by the `.rns` hash tag) agree.
@@ -529,8 +551,8 @@ mod tests {
// VRC4a: a0_bit=1, a1_bit=2. Control is at $F004 (a0=0, a1=1).
// Set latch low byte = 0xE.
m.cpu_write(0xF000, 0xE);
- // Enable: bit 1 (enable now), mode=cycle (bit 2 = 0).
- m.cpu_write(0xF004, 0x02);
+ // Enable: bit 1 (enable now), mode=cycle (bit 2 = 1, VRC_IRQ).
+ m.cpu_write(0xF004, 0x06);
// From counter=latch=0xE, ticks until 0xFF: 0xFF-0xE = 0xF1 ticks
// for the wrap, plus one to set pending.
for _ in 0..0xF1 + 1 {
@@ -539,6 +561,36 @@ mod tests {
assert!(m.irq_pending());
}
+ /// `nesdev_wiki/VRC_IRQ.xhtml`: control bit 2 is M, "IRQ Mode (1 = cycle
+ /// mode, 0 = scanline mode)", and in scanline mode a prescaler divides the
+ /// CPU clock by 113 2/3 before the counter sees it. Until v3.2.0 the bit
+ /// was read inverted, so a game's scanline split fired every CPU cycle
+ /// (Bio Miracle Bokutte Upa lost its status bar; mapper 252's San Guo Zhi
+ /// drew its text box from the wrong CHR banks).
+ #[test]
+ fn vrc4_irq_mode_bit_2_clear_is_scanline_mode() {
+ let mut m = Vrc4::new(synth(8), synth_chr(8), 21, 1, Mirroring::Vertical).unwrap();
+ m.cpu_write(0xF000, 0x0E); // latch $FE: two counter clocks to the IRQ
+ m.cpu_write(0xF002, 0x0F);
+ m.cpu_write(0xF004, 0x02); // enable, M = 0: scanline mode
+ for _ in 0..10 {
+ m.notify_cpu_cycle();
+ }
+ assert!(
+ !m.irq_pending(),
+ "M = 0 is scanline mode: 10 CPU cycles is not two scanlines"
+ );
+ for _ in 0..(2 * 114) {
+ m.notify_cpu_cycle();
+ }
+ assert!(m.irq_pending(), "two prescaled clocks (~227 CPU cycles)");
+ // M = 1 is cycle mode: the counter sees every CPU cycle.
+ m.cpu_write(0xF004, 0x06);
+ m.notify_cpu_cycle();
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending(), "M = 1: two CPU cycles");
+ }
+
/// Core audit v2.9.2 AUD-02: the section carries the 8 KiB PRG-RAM and,
/// on a CHR-RAM board, the 8 KiB CHR-RAM. The core-level pin is
/// `rustynes_core::nes::tests::every_board_snapshot_carries_cartridge_ram`.
diff --git a/crates/rustynes-mappers/src/m022_vrc2.rs b/crates/rustynes-mappers/src/m022_vrc2.rs
index 991962be2..fe579383b 100644
--- a/crates/rustynes-mappers/src/m022_vrc2.rs
+++ b/crates/rustynes-mappers/src/m022_vrc2.rs
@@ -261,11 +261,14 @@ impl Mapper for Vrc2 {
match addr & 0xF000 {
0x8000 => self.prg_lo = value & 0x1F,
0x9000 => {
- self.mirroring = match value & 0x03 {
- 0 => Mirroring::Vertical,
- 1 => Mirroring::Horizontal,
- 2 => Mirroring::SingleScreenA,
- _ => Mirroring::SingleScreenB,
+ // NESdev "VRC2 and VRC4", Mirroring Control: "VRC2 supports
+ // only vertical or horizontal mirroring. Bit 1 is ignored."
+ // (The one-screen values 2 / 3 are VRC4-only.) Until v3.2.0
+ // this decoded all four, like the VRC4.
+ self.mirroring = if (value & 1) == 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
};
}
0xA000 => self.prg_mid = value & 0x1F,
@@ -301,6 +304,18 @@ impl Mapper for Vrc2 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -458,6 +473,43 @@ mod tests {
assert_eq!(m.cpu_read(0x8000), 2, "high bits above 5-bit field ignored");
}
+ /// NES 2.0 submapper 3 on mappers 23 and 25 is the VRC2 (VRC2b / VRC2c),
+ /// which "should not implement VRC4-only capabilities" (nesdev "NES 2.0
+ /// submappers"): it is built as [`Vrc2`] -- no IRQ, H/V mirroring only --
+ /// while submapper 0 stays the VRC4 superset. Until v3.2.0 every 23 / 25
+ /// image was built as VRC4.
+ #[test]
+ fn mappers_23_and_25_submapper_3_build_the_vrc2() {
+ fn rom(mapper: u8, sub: u8) -> Vec {
+ let mut r = vec![0u8; 16 + 128 * 1024 + 128 * 1024];
+ r[..4].copy_from_slice(b"NES\x1A");
+ r[4] = 8;
+ r[5] = 16;
+ r[6] = (mapper & 0x0F) << 4;
+ r[7] = (mapper & 0xF0) | 0x08; // NES 2.0
+ r[8] = sub << 4;
+ r
+ }
+ for mapper in [23u8, 25] {
+ let (_c, mut vrc2) = crate::parse(&rom(mapper, 3)).expect("parses");
+ assert!(!vrc2.caps().irq_source, "mapper {mapper} sub 3: no IRQ");
+ vrc2.cpu_write(0x9000, 2);
+ assert_eq!(
+ vrc2.current_mirroring(),
+ Mirroring::Vertical,
+ "mapper {mapper} sub 3"
+ );
+ let (_c, mut vrc4) = crate::parse(&rom(mapper, 0)).expect("parses");
+ assert!(vrc4.caps().irq_source, "mapper {mapper} sub 0: VRC4 IRQ");
+ vrc4.cpu_write(0x9000, 2);
+ assert_eq!(
+ vrc4.current_mirroring(),
+ Mirroring::SingleScreenA,
+ "mapper {mapper} sub 0"
+ );
+ }
+ }
+
#[test]
fn vrc2_mirroring_control_register() {
let mut m = Vrc2::new(synth(8), synth_chr(8), 22, 0, Mirroring::Vertical).unwrap();
@@ -466,9 +518,9 @@ mod tests {
m.cpu_write(0x9000, 1);
assert_eq!(m.mirroring, Mirroring::Horizontal);
m.cpu_write(0x9000, 2);
- assert_eq!(m.mirroring, Mirroring::SingleScreenA);
+ assert_eq!(m.mirroring, Mirroring::Vertical);
m.cpu_write(0x9000, 3);
- assert_eq!(m.mirroring, Mirroring::SingleScreenB);
+ assert_eq!(m.mirroring, Mirroring::Horizontal);
}
#[test]
diff --git a/crates/rustynes-mappers/src/m024_vrc6.rs b/crates/rustynes-mappers/src/m024_vrc6.rs
index b09f5480d..eb5932999 100644
--- a/crates/rustynes-mappers/src/m024_vrc6.rs
+++ b/crates/rustynes-mappers/src/m024_vrc6.rs
@@ -563,6 +563,18 @@ impl Mapper for Vrc6 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m032_irem_g101.rs b/crates/rustynes-mappers/src/m032_irem_g101.rs
index a41f510a6..51259a975 100644
--- a/crates/rustynes-mappers/src/m032_irem_g101.rs
+++ b/crates/rustynes-mappers/src/m032_irem_g101.rs
@@ -211,6 +211,15 @@ impl Mapper for IremG101 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m033_taito_tc0190.rs b/crates/rustynes-mappers/src/m033_taito_tc0190.rs
index 9e80853c1..079bb6366 100644
--- a/crates/rustynes-mappers/src/m033_taito_tc0190.rs
+++ b/crates/rustynes-mappers/src/m033_taito_tc0190.rs
@@ -190,6 +190,15 @@ impl Mapper for TaitoTc0190 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m034_bnrom_nina001.rs b/crates/rustynes-mappers/src/m034_bnrom_nina001.rs
index a2b0fa2ce..c84a5828b 100644
--- a/crates/rustynes-mappers/src/m034_bnrom_nina001.rs
+++ b/crates/rustynes-mappers/src/m034_bnrom_nina001.rs
@@ -187,6 +187,27 @@ impl Mapper for M34 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match (addr, self.variant) {
+ (0x0000..=0x0FFF, M34Variant::Nina001) => {
+ let total_4k = (self.chr.len() / CHR_BANK_4K).max(1);
+ let bank = (self.chr_bank_lo as usize) % total_4k;
+ self.chr[(bank * CHR_BANK_4K + addr as usize) % self.chr.len()]
+ }
+ (0x1000..=0x1FFF, M34Variant::Nina001) => {
+ let total_4k = (self.chr.len() / CHR_BANK_4K).max(1);
+ let bank = (self.chr_bank_hi as usize) % total_4k;
+ self.chr[(bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr.len()]
+ }
+ (0x0000..=0x1FFF, _) => self.chr[addr as usize % self.chr.len()],
+ (0x2000..=0x3EFF, _) => {
+ self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()]
+ }
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m035_jy_asic.rs b/crates/rustynes-mappers/src/m035_jy_asic.rs
index 0f0c73a00..77fc9fc0a 100644
--- a/crates/rustynes-mappers/src/m035_jy_asic.rs
+++ b/crates/rustynes-mappers/src/m035_jy_asic.rs
@@ -420,6 +420,13 @@ impl JyAsic {
/// Resolve a physical CHR offset for a PPU pattern-table address.
fn chr_offset(&self, addr: u16) -> usize {
+ self.chr_offset_with_latch(addr, self.chr_latch)
+ }
+
+ /// [`Self::chr_offset`] through an explicit MMC4-style latch, so
+ /// `ppu_peek` can resolve through the latch a fetch WOULD set without
+ /// setting it.
+ fn chr_offset_with_latch(&self, addr: u16, chr_latch: [u8; 2]) -> usize {
let addr = (addr & 0x1FFF) as usize;
let total_1k = (self.chr.len() / CHR_BANK_1K).max(1);
let slot_1k = addr / CHR_BANK_1K; // 0..=7
@@ -429,9 +436,9 @@ impl JyAsic {
// 4 KiB: two windows, MMC4 latch picks the register for each.
1 => {
let reg = if addr < 0x1000 {
- self.chr_latch[0] as usize
+ chr_latch[0] as usize
} else {
- self.chr_latch[1] as usize
+ chr_latch[1] as usize
};
(self.chr_reg(reg) as usize) * 4 + (slot_1k & 0x03)
}
@@ -489,17 +496,26 @@ impl JyAsic {
/// Update the MMC4-like CHR latch on mapper 209 when a pattern fetch hits a
/// sentinel address ($x FD8-$x FDF / $x FE8-$x FEF).
fn update_chr_latch_209(&mut self, addr: u16) {
+ self.chr_latch = self.chr_latch_after_fetch(addr);
+ }
+
+ /// The CHR latch as it stands after a pattern fetch of `addr`, without
+ /// storing it: [`Self::update_chr_latch_209`] stores it, `ppu_peek`
+ /// resolves through it (`mapper209_ppu_peek_sees_the_latch_the_read_would_set`).
+ fn chr_latch_after_fetch(&self, addr: u16) -> [u8; 2] {
+ let mut chr_latch = self.chr_latch;
if !matches!(self.board, JyBoard::M209 | JyBoard::M35) {
- return;
+ return chr_latch;
}
match addr & 0x2FF8 {
0x0FD8 | 0x0FE8 => {
// Mesen: chrLatch[addr>>12] = (addr>>4) & ((addr>>10 & 4) | 2)
let idx = (addr >> 12) as usize & 0x01;
- self.chr_latch[idx] = ((addr >> 4) as u8) & (((addr >> 10) as u8 & 0x04) | 0x02);
+ chr_latch[idx] = ((addr >> 4) as u8) & (((addr >> 10) as u8 & 0x04) | 0x02);
}
_ => {}
}
+ chr_latch
}
/// Whether the given nametable index ($2000-$2FFF / index 0..=3) reads from
@@ -728,6 +744,18 @@ impl Mapper for JyAsic {
self.chr[off % self.chr.len()]
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ // The bus masks pattern-table reads to `$0000-$1FFF` before they reach
+ // here; nametable bytes are routed through `nametable_fetch` /
+ // `nametable_address` instead (the PPU owns CIRAM), so this only
+ // services CHR.
+ let addr = addr & 0x1FFF;
+ // `ppu_read` moves the latch BEFORE it resolves the bank, so a fetch
+ // of a latch trigger reads through the new latch; peek the same way.
+ let off = self.chr_offset_with_latch(addr, self.chr_latch_after_fetch(addr));
+ self.chr[off % self.chr.len()]
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
// Pattern-table writes only (CHR-RAM). Nametable writes arrive via
// `nametable_write`; the bus never routes `$2000+` to `ppu_write`.
@@ -1316,6 +1344,38 @@ mod tests {
assert_eq!(m.chr_latch[0], 2);
}
+ /// `ppu_peek` returns the byte `ppu_read` would, and `ppu_read` on 209
+ /// updates the MMC4-style latch BEFORE it resolves the bank, so a fetch
+ /// of a latch trigger reads through the NEW latch. Regression for a
+ /// v3.2.0 import whose `ppu_peek` resolved through the old latch, so the
+ /// debugger's CHR view showed a byte the PPU never fetches.
+ #[test]
+ fn mapper209_ppu_peek_sees_the_latch_the_read_would_set() {
+ // Every byte of 1 KiB bank `b` is `b`, so a byte names its bank.
+ let mut chr = vec![0u8; 256 * CHR_BANK_1K];
+ for (i, v) in chr.iter_mut().enumerate() {
+ *v = (i / CHR_BANK_1K) as u8;
+ }
+ let mut m = JyAsic::new(
+ synth_prg(64),
+ chr.into_boxed_slice(),
+ Mirroring::Vertical,
+ JyBoard::M209,
+ )
+ .unwrap();
+ m.cpu_write(0xD000, 0x08); // CHR mode 1: two 4 KiB windows
+ m.cpu_write(0x9000, 1); // reg 0 -> 4 KiB bank 1 (1 KiB banks 4-7)
+ m.cpu_write(0x9002, 2); // reg 2 -> 4 KiB bank 2 (1 KiB banks 8-11)
+ for addr in [0x0FD8u16, 0x0FE8, 0x1FD8, 0x1FE8, 0x0FE0, 0x0000] {
+ let saved = m.save_state();
+ let read = m.ppu_read(addr);
+ m.load_state(&saved).unwrap();
+ assert_eq!(m.ppu_peek(addr), read, "${addr:04X}");
+ }
+ // And the peek really did see a flip: $0FE8 selects reg 2.
+ assert_eq!(m.ppu_peek(0x0FE8), 11, "bank 2 * 4 + slot 3");
+ }
+
#[test]
fn mapper90_no_mmc4_latch() {
let mut m = fresh(JyBoard::M90);
diff --git a/crates/rustynes-mappers/src/m036_txc_policeman.rs b/crates/rustynes-mappers/src/m036_txc_policeman.rs
index 9ed8d335d..07a63739a 100644
--- a/crates/rustynes-mappers/src/m036_txc_policeman.rs
+++ b/crates/rustynes-mappers/src/m036_txc_policeman.rs
@@ -134,6 +134,19 @@ impl Mapper for Txc36 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m038_bitcorp38.rs b/crates/rustynes-mappers/src/m038_bitcorp38.rs
index 9e7f627b0..202107620 100644
--- a/crates/rustynes-mappers/src/m038_bitcorp38.rs
+++ b/crates/rustynes-mappers/src/m038_bitcorp38.rs
@@ -125,6 +125,19 @@ impl Mapper for Bitcorp38 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m039_subor39.rs b/crates/rustynes-mappers/src/m039_subor39.rs
index aca044b4c..26898e807 100644
--- a/crates/rustynes-mappers/src/m039_subor39.rs
+++ b/crates/rustynes-mappers/src/m039_subor39.rs
@@ -118,6 +118,16 @@ impl Mapper for Subor39 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ // CHR is fixed to bank 0 (8 KiB).
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m041_caltron41.rs b/crates/rustynes-mappers/src/m041_caltron41.rs
index 699fb6f1b..259ae2b49 100644
--- a/crates/rustynes-mappers/src/m041_caltron41.rs
+++ b/crates/rustynes-mappers/src/m041_caltron41.rs
@@ -147,6 +147,15 @@ impl Mapper for Caltron41 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs b/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs
index 1558aeab7..9a607431e 100644
--- a/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs
+++ b/crates/rustynes-mappers/src/m042_fds_conv_bio_miracle.rs
@@ -212,6 +212,22 @@ impl Mapper for Mapper42 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m048_taito_tc0690.rs b/crates/rustynes-mappers/src/m048_taito_tc0690.rs
index 867971241..eedfdec86 100644
--- a/crates/rustynes-mappers/src/m048_taito_tc0690.rs
+++ b/crates/rustynes-mappers/src/m048_taito_tc0690.rs
@@ -240,6 +240,15 @@ impl Mapper for TaitoTc0690 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs b/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs
index 34156b39a..ce6d88c36 100644
--- a/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs
+++ b/crates/rustynes-mappers/src/m050_fds_conv_smb2j.rs
@@ -165,6 +165,15 @@ impl Mapper for Mapper50 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m064_rambo1.rs b/crates/rustynes-mappers/src/m064_rambo1.rs
index 37af15524..7c6ccb1cf 100644
--- a/crates/rustynes-mappers/src/m064_rambo1.rs
+++ b/crates/rustynes-mappers/src/m064_rambo1.rs
@@ -15,6 +15,21 @@
//! See `nesdev_wiki/RAMBO_1.xhtml`, `nesdev_wiki/INES_Mapper_064.xhtml`, and
//! `docs/mappers.md`.
//!
+//! # Mapper 158 (Tengen 800037, *Alien Syndrome*)
+//!
+//! Built with [`Rambo1::new_m158`]. Per `nesdev_wiki/INES_Mapper_158.xhtml` it
+//! "modifies RAMBO-1 (mapper 64) in the exact same way that TLSROM (118)
+//! differs from MMC3 (4)": the chip's CHR A17 output drives CIRAM A10, so "bit
+//! 7 of each CHR bank mapped into PPU `$0000-$0FFF` controls which page of
+//! CIRAM is used for the corresponding nametable in `$2000-$2FFF`"
+//! (`RAMBO_1.xhtml`, Variants). Nametable *n* takes bit 7 of the 1 KiB bank in
+//! slot *n* of the `$0000-$0FFF` half, after the C (A12 inversion) and K
+//! (full 1 KiB) bits; in 2 KiB mode both slots of a 2 KiB bank carry the same
+//! register's bit 7. The `$A000` mirroring register "applies to mapper 64
+//! only", so the board ignores it. Banking, IRQ and save-state layout are
+//! mapper 64's unchanged; the variant is a construction-time property, not
+//! state.
+//!
//! # Banking registers
//!
//! `$8000` (even) bank-select: bits 0-3 pick the register, bit 5 = full-1KiB
@@ -91,6 +106,9 @@ pub struct Rambo1 {
chr_1k_mode: bool, // $8000 bit 5 (K)
mirroring: Mirroring,
+ /// Mapper 158: CIRAM A10 is CHR A17 (bit 7 of the nametable's slot bank),
+ /// and `$A000` is not connected.
+ ciram_from_chr_a17: bool,
// IRQ.
irq_latch: u8,
@@ -150,6 +168,7 @@ impl Rambo1 {
chr_mode: false,
chr_1k_mode: false,
mirroring,
+ ciram_from_chr_a17: false,
irq_latch: 0,
irq_counter: 0,
irq_reload_pending: false,
@@ -163,6 +182,19 @@ impl Rambo1 {
})
}
+ /// Construct the mapper 158 board (Tengen 800037): a RAMBO-1 whose CHR A17
+ /// output selects the CIRAM page per nametable (see the module docs). The
+ /// header's mirroring is meaningless on it and is not taken.
+ ///
+ /// # Errors
+ ///
+ /// As [`Self::new`].
+ pub fn new_m158(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result {
+ let mut m = Self::new(prg_rom, chr_rom, Mirroring::MapperControlled)?;
+ m.ciram_from_chr_a17 = true;
+ Ok(m)
+ }
+
/// RF lives at register index 15.
const RF: usize = 15;
@@ -243,7 +275,12 @@ impl Rambo1 {
fn nametable_offset(&self, addr: u16) -> usize {
let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
let local = (addr as usize) & (NAMETABLE_SIZE - 1);
- let physical = self.mirroring.physical_bank(table);
+ let physical = if self.ciram_from_chr_a17 {
+ // Mapper 158: nametable n reads CIRAM page = CHR A17 of slot n.
+ (self.chr_bank_1k(table as usize) >> 7) & 1
+ } else {
+ self.mirroring.physical_bank(table)
+ };
physical * NAMETABLE_SIZE + local
}
@@ -302,7 +339,8 @@ impl Mapper for Rambo1 {
}
}
0xA000..=0xBFFF => {
- if addr & 1 == 0 {
+ // Mapper 158 does not connect the mirroring register.
+ if addr & 1 == 0 && !self.ciram_from_chr_a17 {
self.mirroring = if value & 1 == 0 {
Mirroring::Vertical
} else {
@@ -347,6 +385,18 @@ impl Mapper for Rambo1 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -371,7 +421,12 @@ impl Mapper for Rambo1 {
}
fn current_mirroring(&self) -> Mirroring {
- self.mirroring
+ if self.ciram_from_chr_a17 {
+ // Per-nametable and dynamic; `nametable_address` resolves it.
+ Mirroring::MapperControlled
+ } else {
+ self.mirroring
+ }
}
fn notify_a12(&mut self, level: bool) {
@@ -416,9 +471,13 @@ impl Mapper for Rambo1 {
fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
let mut info = crate::mapper::MapperDebugInfo {
- mapper_id: 64,
- name: "Tengen RAMBO-1 (64)".into(),
- mirroring: crate::mapper::mirroring_name(self.mirroring),
+ mapper_id: if self.ciram_from_chr_a17 { 158 } else { 64 },
+ name: if self.ciram_from_chr_a17 {
+ "Tengen 800037 / RAMBO-1 (158)".into()
+ } else {
+ "Tengen RAMBO-1 (64)".into()
+ },
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
..Default::default()
};
info.prg_banks
@@ -742,4 +801,94 @@ mod tests {
assert_eq!(m.irq_cpu_mode, m2.irq_cpu_mode);
assert_eq!(m.ppu_read(0x2000), m2.ppu_read(0x2000));
}
+
+ // --- Mapper 158 (Tengen 800037) ------------------------------------
+
+ /// A mapper 158 image through `crate::parse`, so the dispatch arm is
+ /// under test: 128 KiB PRG, 128 KiB CHR, header says horizontal.
+ fn m158() -> Box {
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = 8;
+ v[5] = 16;
+ v[6] = 0xE0; // mapper low nibble 0xE, horizontal
+ v[7] = 0x90; // mapper high nibble 0x9 -> 0x9E = 158
+ v.extend_from_slice(&synth_prg(16));
+ v.extend_from_slice(&synth_chr(128));
+ crate::parse(&v)
+ .unwrap_or_else(|e| panic!("mapper 158 must parse: {e:?}"))
+ .1
+ }
+
+ /// The CIRAM page (0 or 1) nametable `n` resolves to.
+ fn page(m: &dyn Mapper, n: u16) -> u16 {
+ m.nametable_address(0x2000 + n * 0x400 + 0x15) >> 10
+ }
+
+ fn sel(m: &mut dyn Mapper, reg: u8, value: u8) {
+ m.cpu_write(0x8000, reg);
+ m.cpu_write(0x8001, value);
+ }
+
+ #[test]
+ fn m158_nametables_follow_bit7_of_the_0000_half_banks_in_2k_mode() {
+ let mut m = m158();
+ assert_eq!(m.current_mirroring(), Mirroring::MapperControlled);
+ sel(m.as_mut(), 0, 0x80); // R0: CHR A17 = 1 for slots 0-1
+ sel(m.as_mut(), 1, 0x02); // R1: CHR A17 = 0 for slots 2-3
+ let pages: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect();
+ assert_eq!(pages, [1, 1, 0, 0], "2 KiB R0/R1 drive two tables each");
+ // The local offset survives.
+ assert_eq!(m.nametable_address(0x2015) & 0x3FF, 0x15);
+ // $A000 is not connected on this board.
+ m.cpu_write(0xA000, 0x01);
+ m.cpu_write(0xA000, 0x00);
+ let again: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect();
+ assert_eq!(again, [1, 1, 0, 0], "$A000 ignored");
+ }
+
+ #[test]
+ fn m158_full_1k_mode_uses_r0_r8_r1_r9() {
+ let mut m = m158();
+ // K=1 (bit 5): slots 0-3 = R0, R8, R1, R9.
+ m.cpu_write(0x8000, 0x20 | 8);
+ m.cpu_write(0x8001, 0x80); // R8
+ m.cpu_write(0x8000, 0x20 | 9);
+ m.cpu_write(0x8001, 0x00); // R9
+ m.cpu_write(0x8000, 0x20);
+ m.cpu_write(0x8001, 0x00); // R0
+ m.cpu_write(0x8000, 0x20 | 1);
+ m.cpu_write(0x8001, 0x81); // R1
+ let pages: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect();
+ assert_eq!(pages, [0, 1, 1, 0]);
+ }
+
+ #[test]
+ fn m158_a12_inversion_moves_the_source_to_r2_r5() {
+ let mut m = m158();
+ // C=1 (bit 7): $0000-$0FFF is R2..R5.
+ for (r, v) in [(2u8, 0x80u8), (3, 0x00), (4, 0x80), (5, 0x00)] {
+ m.cpu_write(0x8000, 0x80 | r);
+ m.cpu_write(0x8001, v);
+ }
+ let pages: Vec = (0..4).map(|n| page(m.as_ref(), n)).collect();
+ assert_eq!(pages, [1, 0, 1, 0]);
+ }
+
+ #[test]
+ fn m158_state_round_trips_and_mapper_64_keeps_its_register() {
+ let mut a = m158();
+ sel(a.as_mut(), 0, 0x80);
+ a.ppu_write(0x2000, 0x5A);
+ let blob = a.save_state();
+ let mut b = m158();
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.ppu_read(0x2000), 0x5A);
+ assert_eq!(page(b.as_ref(), 0), 1);
+ // Mapper 64 is untouched: $A000 still sets H/V.
+ let mut m = fresh();
+ m.cpu_write(0xA000, 0x01);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
}
diff --git a/crates/rustynes-mappers/src/m065_irem_h3001.rs b/crates/rustynes-mappers/src/m065_irem_h3001.rs
index 5624f2c82..b6a35beb6 100644
--- a/crates/rustynes-mappers/src/m065_irem_h3001.rs
+++ b/crates/rustynes-mappers/src/m065_irem_h3001.rs
@@ -221,6 +221,18 @@ impl Mapper for IremH3001 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m066_gxrom.rs b/crates/rustynes-mappers/src/m066_gxrom.rs
index 0f64092b0..2c78a3431 100644
--- a/crates/rustynes-mappers/src/m066_gxrom.rs
+++ b/crates/rustynes-mappers/src/m066_gxrom.rs
@@ -24,6 +24,8 @@ pub struct GxRom {
vram: Box<[u8]>,
prg_bank: u8,
chr_bank: u8,
+ prg_bank_offset: usize,
+ chr_bank_offset: usize,
mirroring: Mirroring,
}
@@ -56,6 +58,10 @@ impl GxRom {
vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
prg_bank: 0,
chr_bank: 0,
+ // Bank 0 at power-on; `update_bank_offsets` keeps these in step
+ // with `prg_bank` / `chr_bank` on every write and restore.
+ prg_bank_offset: 0,
+ chr_bank_offset: 0,
mirroring,
})
}
@@ -67,11 +73,18 @@ impl GxRom {
physical * NAMETABLE_SIZE + local
}
+ #[inline]
+ fn update_bank_offsets(&mut self) {
+ let prg_bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
+ self.prg_bank_offset = ((self.prg_bank as usize) % prg_bank_count) * PRG_BANK_32K;
+
+ let chr_bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ self.chr_bank_offset = ((self.chr_bank as usize) % chr_bank_count) * CHR_BANK_8K;
+ }
+
fn read_prg(&self, addr: u16) -> u8 {
- let bank_count = (self.prg_rom.len() / PRG_BANK_32K).max(1);
- let bank = (self.prg_bank as usize) % bank_count;
let off = (addr - 0x8000) as usize;
- self.prg_rom[bank * PRG_BANK_32K + off]
+ self.prg_rom[self.prg_bank_offset + off]
}
}
@@ -97,17 +110,23 @@ impl Mapper for GxRom {
let effective = value & prg_byte;
self.prg_bank = (effective >> 4) & 0x03;
self.chr_bank = effective & 0x03;
+ self.update_bank_offsets();
}
}
fn ppu_read(&mut self, addr: u16) -> u8 {
let addr = addr & 0x3FFF;
match addr {
- 0x0000..=0x1FFF => {
- let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
- let bank = (self.chr_bank as usize) % bank_count;
- self.chr_rom[bank * CHR_BANK_8K + (addr as usize)]
- }
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank_offset + (addr as usize)],
0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
_ => 0,
}
@@ -153,6 +172,9 @@ impl Mapper for GxRom {
self.prg_bank = data[1];
self.chr_bank = data[2];
self.vram.copy_from_slice(&data[3..3 + self.vram.len()]);
+ // The offsets are derived state, not saved: recompute them from the
+ // restored bank registers.
+ self.update_bank_offsets();
Ok(())
}
}
@@ -191,6 +213,27 @@ mod tests {
assert_eq!(m.ppu_read(0x0000), 3);
}
+ /// A restored state reads through the restored banks. Regression for a
+ /// v3.2.0 import that cached the PRG / CHR bank offsets but did not
+ /// recompute them in `load_state`, so a restore into a fresh instance
+ /// kept reading bank 0 until the next register write.
+ #[test]
+ fn gxrom_load_state_restores_the_bank_offsets() {
+ let mut m = GxRom::new(synth_prg(4), synth_chr(4), Mirroring::Vertical).unwrap();
+ m.cpu_write(0x8001, 0x21); // PRG bank 2, CHR bank 1
+ let state = m.save_state();
+ let mut fresh = GxRom::new(synth_prg(4), synth_chr(4), Mirroring::Vertical).unwrap();
+ assert_eq!(
+ fresh.cpu_read(0x8000),
+ 0,
+ "fixture: a fresh board is on bank 0"
+ );
+ fresh.load_state(&state).unwrap();
+ assert_eq!(fresh.cpu_read(0x8000), 2, "PRG bank 2 after restore");
+ assert_eq!(fresh.ppu_read(0x0000), 1, "CHR bank 1 after restore");
+ assert_eq!(fresh.ppu_peek(0x0000), 1, "and through the peek path");
+ }
+
#[test]
fn gxrom_bus_conflict_at_offset_zero() {
// At offset 0 of bank 0, byte = 0x00. ANDing kills the write.
diff --git a/crates/rustynes-mappers/src/m067_sunsoft3.rs b/crates/rustynes-mappers/src/m067_sunsoft3.rs
index d2af017ad..6d51de1b7 100644
--- a/crates/rustynes-mappers/src/m067_sunsoft3.rs
+++ b/crates/rustynes-mappers/src/m067_sunsoft3.rs
@@ -212,6 +212,18 @@ impl Mapper for Sunsoft3 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m068_sunsoft4.rs b/crates/rustynes-mappers/src/m068_sunsoft4.rs
index bff1b0e30..8d5bb3924 100644
--- a/crates/rustynes-mappers/src/m068_sunsoft4.rs
+++ b/crates/rustynes-mappers/src/m068_sunsoft4.rs
@@ -252,6 +252,27 @@ impl Mapper for Sunsoft4 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => {
+ // CHR-ROM nametable mode is handled by `nametable_fetch`
+ // (the PPU consults it first). This fallback path serves
+ // CIRAM for the non-ROM-nametable case.
+ if self.nt_rom_mode {
+ self.chr_nt_byte(addr | 0x2000)
+ } else {
+ self.vram[self.nametable_offset(addr) % self.vram.len()]
+ }
+ }
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs
index b35ef6792..744b7b1c1 100644
--- a/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs
+++ b/crates/rustynes-mappers/src/m069_sunsoft_fme7.rs
@@ -520,22 +520,23 @@ impl Sunsoft5BAudio {
#[cfg(feature = "mapper-audio")]
pub(crate) fn mix(&self) -> i32 {
let mut sum: i32 = 0;
- for (ch, tone) in [&self.tone_a, &self.tone_b, &self.tone_c]
- .iter()
- .enumerate()
- {
- let ch = ch as u8;
- // Per wiki: "If both bits are 1 [disable + disable], the
- // channel outputs a constant signal at the specified volume.
- // If both bits are 0, the result is the logical and of noise
- // and tone." Equivalent: emit when (tone_enabled => square
- // high) AND (noise_enabled => noise high), defaulting either
- // factor to "1" when its source is disabled.
- let tone_factor = !self.tone_enabled(ch) || tone.level != 0;
- let noise_factor = !self.noise_enabled(ch) || self.noise.level() != 0;
- if tone_factor && noise_factor {
- sum += self.amplitude(ch);
- }
+
+ let tone_factor_a = !self.tone_enabled(0) || self.tone_a.level != 0;
+ let noise_factor_a = !self.noise_enabled(0) || self.noise.level() != 0;
+ if tone_factor_a && noise_factor_a {
+ sum += self.amplitude(0);
+ }
+
+ let tone_factor_b = !self.tone_enabled(1) || self.tone_b.level != 0;
+ let noise_factor_b = !self.noise_enabled(1) || self.noise.level() != 0;
+ if tone_factor_b && noise_factor_b {
+ sum += self.amplitude(1);
+ }
+
+ let tone_factor_c = !self.tone_enabled(2) || self.tone_c.level != 0;
+ let noise_factor_c = !self.noise_enabled(2) || self.noise.level() != 0;
+ if tone_factor_c && noise_factor_c {
+ sum += self.amplitude(2);
}
// Scale the shape table to the hardware-relative level (see
// `SUNSOFT5B_MIX_SCALE_NUM`), then centre on zero so the BLEP buffer
@@ -862,6 +863,21 @@ impl Mapper for Fme7 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let total_1k = (self.chr_rom.len() / CHR_BANK_1K).max(1);
+ let slot = addr as usize / CHR_BANK_1K;
+ let bank = (self.chr[slot] as usize) % total_1k;
+ let off = bank * CHR_BANK_1K + (addr as usize & (CHR_BANK_1K - 1));
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m070_bandai74.rs b/crates/rustynes-mappers/src/m070_bandai74.rs
index 3e4c5c0e0..ccf53f2d8 100644
--- a/crates/rustynes-mappers/src/m070_bandai74.rs
+++ b/crates/rustynes-mappers/src/m070_bandai74.rs
@@ -147,6 +147,15 @@ impl Mapper for Bandai74 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs
index fd742af21..2a1e681e6 100644
--- a/crates/rustynes-mappers/src/m071_camerica_bf9093.rs
+++ b/crates/rustynes-mappers/src/m071_camerica_bf9093.rs
@@ -42,7 +42,13 @@ pub struct Camerica {
vram: Box<[u8]>,
prg_bank: u8,
mirroring: Mirroring,
+ /// The `$9000` single-screen register is live (Fire Hawk's BF9097).
+ /// Saved state: detection can turn it on mid-run.
has_single_screen: bool,
+ /// Turn the register on at the first `$9000-$9FFF` write (submapper 0;
+ /// see [`Self::with_single_screen_detection`]). Configuration from the
+ /// header, not state, so it is not saved.
+ detect_single_screen: bool,
}
impl Camerica {
@@ -69,8 +75,26 @@ impl Camerica {
prg_bank: 0,
mirroring,
has_single_screen,
+ detect_single_screen: false,
})
}
+
+ /// Enable Fire Hawk detection: the board keeps its header mirroring
+ /// until the first write to `$9000-$9FFF`, then behaves as the BF9097,
+ /// whose register there selects single-screen mirroring.
+ ///
+ /// nesdev "INES Mapper 071": only Fire Hawk has the register, it writes
+ /// it only at `$9000`, and "for compatibility without using a submapper"
+ /// a board starts with fixed mirroring and applies single-screen once
+ /// `$9000-$9FFF` is written. NES 2.0 submapper 1 names Fire Hawk
+ /// outright (the `has_single_screen` constructor flag). Used for
+ /// submapper 0 only (maintainer ruling, 2026-10-13) -- which covers every
+ /// iNES 1.0 image, where the submapper reads as 0. Added in v3.2.0.
+ #[must_use]
+ pub const fn with_single_screen_detection(mut self, on: bool) -> Self {
+ self.detect_single_screen = on;
+ self
+ }
}
impl Mapper for Camerica {
@@ -97,7 +121,8 @@ impl Mapper for Camerica {
fn cpu_write(&mut self, addr: u16, value: u8) {
match addr {
- 0x9000..=0x9FFF if self.has_single_screen => {
+ 0x9000..=0x9FFF if self.has_single_screen || self.detect_single_screen => {
+ self.has_single_screen = true;
self.mirroring = if value & 0x10 == 0 {
Mirroring::SingleScreenA
} else {
@@ -118,6 +143,15 @@ impl Mapper for Camerica {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize % self.chr_ram.len()],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -203,4 +237,74 @@ mod tests {
// bank 5 at 16K offset. Let's just check it swaps from 0.
assert_ne!(m.cpu_read(0x8000), 0);
}
+
+ /// nesdev "INES Mapper 071" / "NES 2.0 submappers" (071): only Fire Hawk's
+ /// BF9097 has the `$9000` single-screen register, NES 2.0 submapper 1
+ /// names it, and "for compatibility without using a submapper" a board
+ /// starts with its header mirroring and switches to single-screen once
+ /// `$9000-$9FFF` is written. Maintainer ruling (2026-10-13): that
+ /// detection applies on submapper 0 only.
+ #[test]
+ fn submapper_0_detects_fire_hawk_single_screen_on_a_9000_write() {
+ let mut m = Camerica::new(synth(16), Mirroring::Vertical, false)
+ .unwrap()
+ .with_single_screen_detection(true);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical, "header first");
+ m.cpu_write(0x9000, 0x10);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
+ m.cpu_write(0x9000, 0x00);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
+ assert_eq!(m.cpu_read(0x8000), 0, "a $9000 write is not a bank select");
+ // The detected mode survives a state round trip.
+ let state = m.save_state();
+ let mut other = Camerica::new(synth(16), Mirroring::Vertical, false)
+ .unwrap()
+ .with_single_screen_detection(true);
+ other.load_state(&state).unwrap();
+ assert_eq!(other.current_mirroring(), Mirroring::SingleScreenA);
+ }
+
+ /// Submapper 1 (Fire Hawk) has the register from power-on.
+ #[test]
+ fn submapper_1_has_the_register_from_power_on() {
+ let mut m = Camerica::new(synth(16), Mirroring::Vertical, true).unwrap();
+ m.cpu_write(0x9000, 0x10);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenB);
+ }
+
+ /// The header wiring in `parse`: an iNES 1.0 image (submapper 0) detects,
+ /// NES 2.0 submapper 1 has the register from power-on, and NES 2.0
+ /// submapper 2 (no meaning assigned) keeps its header mirroring.
+ #[test]
+ fn parse_wires_detection_from_the_submapper() {
+ fn rom(nes2_sub: Option) -> Vec {
+ let mut r = vec![0u8; 16 + 128 * 1024];
+ r[..4].copy_from_slice(b"NES\x1A");
+ r[4] = 8; // 128 KiB PRG, CHR-RAM
+ r[6] = 0x71; // mapper 71 low nibble, vertical
+ r[7] = 0x40; // mapper 71 high nibble
+ if let Some(sub) = nes2_sub {
+ r[7] |= 0x08;
+ r[8] = sub << 4;
+ }
+ r
+ }
+ for (sub, expect) in [
+ (None, Mirroring::SingleScreenB),
+ (Some(1), Mirroring::SingleScreenB),
+ (Some(2), Mirroring::Vertical),
+ ] {
+ let (_cart, mut m) = crate::parse(&rom(sub)).expect("mapper 71 parses");
+ m.cpu_write(0x9000, 0x10);
+ assert_eq!(m.current_mirroring(), expect, "submapper {sub:?}");
+ }
+ }
+
+ /// Any other submapper keeps its header mirroring: no detection.
+ #[test]
+ fn without_detection_a_9000_write_keeps_the_header_mirroring() {
+ let mut m = Camerica::new(synth(16), Mirroring::Vertical, false).unwrap();
+ m.cpu_write(0x9000, 0x10);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ }
}
diff --git a/crates/rustynes-mappers/src/m073_vrc3.rs b/crates/rustynes-mappers/src/m073_vrc3.rs
index 53040d500..1f0b6e445 100644
--- a/crates/rustynes-mappers/src/m073_vrc3.rs
+++ b/crates/rustynes-mappers/src/m073_vrc3.rs
@@ -183,6 +183,15 @@ impl Mapper for Vrc3 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m075_vrc1.rs b/crates/rustynes-mappers/src/m075_vrc1.rs
index 39d6fdfa7..b6be07862 100644
--- a/crates/rustynes-mappers/src/m075_vrc1.rs
+++ b/crates/rustynes-mappers/src/m075_vrc1.rs
@@ -170,6 +170,24 @@ impl Mapper for Vrc1 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x0FFF => {
+ let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
+ let bank = (((self.chr_lo_msb as usize) << 4) | (self.chr_lo as usize)) % total_4k;
+ self.chr_rom[(bank * CHR_BANK_4K + addr as usize) % self.chr_rom.len()]
+ }
+ 0x1000..=0x1FFF => {
+ let total_4k = (self.chr_rom.len() / CHR_BANK_4K).max(1);
+ let bank = (((self.chr_hi_msb as usize) << 4) | (self.chr_hi as usize)) % total_4k;
+ self.chr_rom[(bank * CHR_BANK_4K + (addr as usize - 0x1000)) % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m076_namcot3446.rs b/crates/rustynes-mappers/src/m076_namcot3446.rs
index bba557709..1ebc4397d 100644
--- a/crates/rustynes-mappers/src/m076_namcot3446.rs
+++ b/crates/rustynes-mappers/src/m076_namcot3446.rs
@@ -154,6 +154,20 @@ impl Mapper for Namcot3446M76 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let slot = (addr >> 11) as usize & 0x03;
+ let count = (self.chr_rom.len() / CHR_BANK_2K).max(1);
+ let bank = (self.chr_banks[slot] as usize) % count;
+ self.chr_rom[bank * CHR_BANK_2K + (addr as usize & 0x07FF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m077_irem_napoleon.rs b/crates/rustynes-mappers/src/m077_irem_napoleon.rs
index 797bbaa44..14140c719 100644
--- a/crates/rustynes-mappers/src/m077_irem_napoleon.rs
+++ b/crates/rustynes-mappers/src/m077_irem_napoleon.rs
@@ -138,6 +138,21 @@ impl Mapper for Irem77 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x07FF => {
+ // Bottom 2 KiB: switchable CHR-ROM bank.
+ let count = (self.chr_rom.len() / CHR_BANK_2K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_2K + addr as usize]
+ }
+ 0x0800..=0x1FFF => self.chr_ram[addr as usize - 0x0800],
+ 0x2000..=0x3EFF => self.nt_ram[Self::nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
// $0000-$07FF is CHR-ROM (read-only); everything else is RAM.
diff --git a/crates/rustynes-mappers/src/m078_irem_jaleco78.rs b/crates/rustynes-mappers/src/m078_irem_jaleco78.rs
index 0c9e1a9a5..456e4f1f9 100644
--- a/crates/rustynes-mappers/src/m078_irem_jaleco78.rs
+++ b/crates/rustynes-mappers/src/m078_irem_jaleco78.rs
@@ -199,6 +199,18 @@ impl Mapper for M78 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs
index c23004b60..1bc8b87f2 100644
--- a/crates/rustynes-mappers/src/m079_ave_nina03_06.rs
+++ b/crates/rustynes-mappers/src/m079_ave_nina03_06.rs
@@ -150,6 +150,15 @@ impl Mapper for Nina0379 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m080_taito_x1_005.rs b/crates/rustynes-mappers/src/m080_taito_x1_005.rs
index b4b9d5353..f2a981b6b 100644
--- a/crates/rustynes-mappers/src/m080_taito_x1_005.rs
+++ b/crates/rustynes-mappers/src/m080_taito_x1_005.rs
@@ -34,6 +34,19 @@
//! reset bank and blanked `Kyonshiizu 2`; `$7EF6` polarity is 0=Horz/1=Vert.)
//!
//! See `docs/mappers.md` §Mapper coverage matrix.
+//!
+//! # Mapper 207 (*Fudou Myouou Den*)
+//!
+//! Built with [`TaitoX1005::new_m207`]. Per `nesdev_wiki/INES_Mapper_207.xhtml`
+//! the board "modifies Taito's X1-005 (80) in the exact same way that TLSROM
+//! (118) differs from MMC3 (4)": the chip's CHR A17 output drives CIRAM A10.
+//! The page's register list gives `$7EF0` and `$7EF1` as `[MCCC CCCC]`, with M
+//! (bit 7, which is CHR A17 of the 2 KiB bank) selecting the CIRAM page for
+//! the top (`$7EF0`: `$2000`, `$2400`) and bottom (`$7EF1`: `$2800`,
+//! `$2C00`) nametable rows, "thus [achieving] 1scA/1scB/H controllable
+//! mirroring". `$7EF6-$7EF7`, the "old mirroring control register", is
+//! ignored. Everything else is mapper 80's. The variant is a construction-time
+//! property, not state, so the save-state layout is unchanged.
#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
@@ -70,6 +83,9 @@ pub struct TaitoX1005 {
/// `$7EF8` / `$7EF9` enable-latch; RAM is readable/writable only when both
/// hold `$A3`.
ram_enable: [u8; 2],
+ /// Mapper 207: CIRAM A10 is CHR A17 (bit 7 of `$7EF0` / `$7EF1`), and
+ /// `$7EF6-$7EF7` are not connected.
+ ciram_from_chr_a17: bool,
}
impl TaitoX1005 {
@@ -113,9 +129,23 @@ impl TaitoX1005 {
prg_bank: [0, 1, 2],
mirroring,
ram_enable: [0, 0],
+ ciram_from_chr_a17: false,
})
}
+ /// Construct the mapper 207 board (*Fudou Myouou Den*): an X1-005 whose
+ /// CHR A17 output selects the CIRAM page per nametable row (see the module
+ /// docs). The header's mirroring does not apply and is not taken.
+ ///
+ /// # Errors
+ ///
+ /// As [`Self::new`].
+ pub fn new_m207(prg_rom: Box<[u8]>, chr_rom: Box<[u8]>) -> Result {
+ let mut m = Self::new(prg_rom, chr_rom, Mirroring::MapperControlled)?;
+ m.ciram_from_chr_a17 = true;
+ Ok(m)
+ }
+
const fn ram_enabled(&self) -> bool {
self.ram_enable[0] == RAM_MAGIC && self.ram_enable[1] == RAM_MAGIC
}
@@ -123,7 +153,14 @@ impl TaitoX1005 {
const fn nametable_offset(&self, addr: u16) -> usize {
let table = (((addr - 0x2000) / NAMETABLE_SIZE_U16) & 0x03) as u8;
let local = (addr as usize) & (NAMETABLE_SIZE - 1);
- let physical = self.mirroring.physical_bank(table);
+ let physical = if self.ciram_from_chr_a17 {
+ // Mapper 207: rows 0-1 follow $7EF0 bit 7 (CHR slot 0), rows
+ // 2-3 follow $7EF1 bit 7 (CHR slot 2).
+ let reg = self.chr_1k[if table < 2 { 0 } else { 2 }];
+ (reg >> 7) as usize
+ } else {
+ self.mirroring.physical_bank(table)
+ };
physical * NAMETABLE_SIZE + local
}
@@ -201,6 +238,8 @@ impl Mapper for TaitoX1005 {
0x7EF3 => self.chr_1k[5] = value,
0x7EF4 => self.chr_1k[6] = value,
0x7EF5 => self.chr_1k[7] = value,
+ // Mapper 207: the old mirroring register is ignored.
+ 0x7EF6 | 0x7EF7 if self.ciram_from_chr_a17 => {}
0x7EF6 | 0x7EF7 => {
// $7EF6 bit 0: 0 = Horizontal, 1 = Vertical (nesdev mapper 080;
// Mesen2 TaitoX1005).
@@ -231,6 +270,15 @@ impl Mapper for TaitoX1005 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -248,15 +296,29 @@ impl Mapper for TaitoX1005 {
}
}
+ fn nametable_address(&self, addr: u16) -> u16 {
+ // Identical to the trait default on mapper 80; mapper 207 needs its
+ // per-row CIRAM page.
+ u16::try_from(self.nametable_offset(addr)).unwrap_or(0)
+ }
+
fn current_mirroring(&self) -> Mirroring {
- self.mirroring
+ if self.ciram_from_chr_a17 {
+ Mirroring::MapperControlled
+ } else {
+ self.mirroring
+ }
}
fn debug_info(&self) -> crate::mapper::MapperDebugInfo {
let mut info = crate::mapper::MapperDebugInfo {
- mapper_id: 80,
- name: "Taito X1-005 (80)".into(),
- mirroring: crate::mapper::mirroring_name(self.mirroring),
+ mapper_id: if self.ciram_from_chr_a17 { 207 } else { 80 },
+ name: if self.ciram_from_chr_a17 {
+ "Taito X1-005, CHR A17 mirroring (207)".into()
+ } else {
+ "Taito X1-005 (80)".into()
+ },
+ mirroring: crate::mapper::mirroring_name(self.current_mirroring()),
..Default::default()
};
for (i, b) in self.prg_bank.iter().enumerate() {
@@ -459,4 +521,80 @@ mod tests {
assert_eq!(m.current_mirroring(), m2.current_mirroring());
assert_eq!(m2.cpu_read(0x7F10), 0xAB);
}
+
+ // --- Mapper 207 (Fudou Myouou Den) ----------------------------------
+
+ /// A mapper 207 image through `crate::parse`: 256 KiB PRG, 128 KiB CHR,
+ /// header says vertical.
+ fn m207() -> Box {
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = 16;
+ v[5] = 16;
+ v[6] = 0xF1; // 207 = 0xCF: low nibble 0xF, vertical
+ v[7] = 0xC0;
+ v.extend_from_slice(&synth_prg(32));
+ v.extend_from_slice(&synth_chr_1k(128));
+ crate::parse(&v)
+ .unwrap_or_else(|e| panic!("mapper 207 must parse: {e:?}"))
+ .1
+ }
+
+ fn pages(m: &dyn Mapper) -> [u16; 4] {
+ [0u16, 1, 2, 3].map(|n| m.nametable_address(0x2000 + n * 0x400 + 0x21) >> 10)
+ }
+
+ #[test]
+ fn m207_7ef0_bit7_pages_the_top_row_and_7ef1_the_bottom() {
+ let mut m = m207();
+ assert_eq!(m.current_mirroring(), Mirroring::MapperControlled);
+ m.cpu_write(0x7EF0, 0x80);
+ m.cpu_write(0x7EF1, 0x00);
+ assert_eq!(pages(m.as_ref()), [1, 1, 0, 0], "horizontal");
+ m.cpu_write(0x7EF0, 0x02);
+ assert_eq!(pages(m.as_ref()), [0, 0, 0, 0], "1scA");
+ m.cpu_write(0x7EF0, 0xFE);
+ m.cpu_write(0x7EF1, 0x81);
+ assert_eq!(pages(m.as_ref()), [1, 1, 1, 1], "1scB");
+ assert_eq!(m.nametable_address(0x2C21) & 0x3FF, 0x21);
+ // The CHR banks are still the 2 KiB banks mapper 80 decodes.
+ m.cpu_write(0x7EF0, 0x86);
+ assert_eq!(
+ m.ppu_read(0x0000),
+ 6,
+ "$86 -> 1 KiB bank 134, wrapped to 6 of 128"
+ );
+ assert_eq!(m.ppu_read(0x0400), 7);
+ }
+
+ #[test]
+ fn m207_ignores_the_old_mirroring_register() {
+ let mut m = m207();
+ m.cpu_write(0x7EF0, 0x00);
+ m.cpu_write(0x7EF1, 0x80);
+ m.cpu_write(0x7EF6, 0x01);
+ m.cpu_write(0x7EF7, 0x00);
+ assert_eq!(pages(m.as_ref()), [0, 0, 1, 1]);
+ assert_eq!(m.current_mirroring(), Mirroring::MapperControlled);
+ }
+
+ #[test]
+ fn m207_state_round_trips_and_mapper_80_keeps_7ef6() {
+ let mut a = m207();
+ a.cpu_write(0x7EF1, 0x80);
+ a.ppu_write(0x2800, 0x3C);
+ let blob = a.save_state();
+ let mut b = m207();
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.ppu_read(0x2800), 0x3C);
+ assert_eq!(pages(b.as_ref()), [0, 0, 1, 1]);
+ let mut m80 =
+ TaitoX1005::new(synth_prg(16), synth_chr_1k(128), Mirroring::Horizontal).unwrap();
+ m80.cpu_write(0x7EF6, 0x01);
+ assert_eq!(m80.current_mirroring(), Mirroring::Vertical);
+ m80.cpu_write(0x7EF0, 0x80);
+ assert_eq!(m80.nametable_address(0x2000) >> 10, 0, "80 ignores bit 7");
+ assert_eq!(m80.nametable_address(0x2400) >> 10, 1);
+ }
}
diff --git a/crates/rustynes-mappers/src/m082_taito_x1_017.rs b/crates/rustynes-mappers/src/m082_taito_x1_017.rs
index 28e3d764f..da638fc32 100644
--- a/crates/rustynes-mappers/src/m082_taito_x1_017.rs
+++ b/crates/rustynes-mappers/src/m082_taito_x1_017.rs
@@ -254,6 +254,15 @@ impl Mapper for TaitoX1017 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m083_cony.rs b/crates/rustynes-mappers/src/m083_cony.rs
index 25a372311..1cab8c95a 100644
--- a/crates/rustynes-mappers/src/m083_cony.rs
+++ b/crates/rustynes-mappers/src/m083_cony.rs
@@ -315,6 +315,15 @@ impl Mapper for Cony83 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m085_vrc7.rs b/crates/rustynes-mappers/src/m085_vrc7.rs
index 0da4f89b7..772364c11 100644
--- a/crates/rustynes-mappers/src/m085_vrc7.rs
+++ b/crates/rustynes-mappers/src/m085_vrc7.rs
@@ -465,6 +465,18 @@ impl Mapper for Vrc7 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m087_jaleco87.rs b/crates/rustynes-mappers/src/m087_jaleco87.rs
index 8906413c6..0c2893526 100644
--- a/crates/rustynes-mappers/src/m087_jaleco87.rs
+++ b/crates/rustynes-mappers/src/m087_jaleco87.rs
@@ -132,6 +132,19 @@ impl Mapper for Jaleco87 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let bank_count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % bank_count;
+ self.chr_rom[bank * CHR_BANK_8K + (addr as usize)]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m088_namco118.rs b/crates/rustynes-mappers/src/m088_namco118.rs
index 386dc4ecd..a1c9816b2 100644
--- a/crates/rustynes-mappers/src/m088_namco118.rs
+++ b/crates/rustynes-mappers/src/m088_namco118.rs
@@ -262,6 +262,18 @@ impl Mapper for Namco118 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr[off % self.chr.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m089_sunsoft2.rs b/crates/rustynes-mappers/src/m089_sunsoft2.rs
index 1a7b520e9..786bb24d4 100644
--- a/crates/rustynes-mappers/src/m089_sunsoft2.rs
+++ b/crates/rustynes-mappers/src/m089_sunsoft2.rs
@@ -165,6 +165,15 @@ impl Mapper for Sunsoft2 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m091_jy_sf3.rs b/crates/rustynes-mappers/src/m091_jy_sf3.rs
index 6d2bd89e3..8a30a6d8c 100644
--- a/crates/rustynes-mappers/src/m091_jy_sf3.rs
+++ b/crates/rustynes-mappers/src/m091_jy_sf3.rs
@@ -255,6 +255,15 @@ impl Mapper for Jy91 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m093_sunsoft3r.rs b/crates/rustynes-mappers/src/m093_sunsoft3r.rs
index 04b826fff..8ebcada79 100644
--- a/crates/rustynes-mappers/src/m093_sunsoft3r.rs
+++ b/crates/rustynes-mappers/src/m093_sunsoft3r.rs
@@ -147,6 +147,22 @@ impl Mapper for Sunsoft3r {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_ram_enabled {
+ self.chr_ram[addr as usize]
+ } else {
+ // CHR disabled: reads are open bus (0 here).
+ 0
+ }
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m094_un1rom.rs b/crates/rustynes-mappers/src/m094_un1rom.rs
index 34289a47c..fbadd890d 100644
--- a/crates/rustynes-mappers/src/m094_un1rom.rs
+++ b/crates/rustynes-mappers/src/m094_un1rom.rs
@@ -134,6 +134,15 @@ impl Mapper for Un1rom94 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m095_namcot3425.rs b/crates/rustynes-mappers/src/m095_namcot3425.rs
index c5abf2a9b..d562e7f7c 100644
--- a/crates/rustynes-mappers/src/m095_namcot3425.rs
+++ b/crates/rustynes-mappers/src/m095_namcot3425.rs
@@ -165,6 +165,15 @@ impl Mapper for Namcot3425M95 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.read_chr(addr),
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
diff --git a/crates/rustynes-mappers/src/m096_bandai96.rs b/crates/rustynes-mappers/src/m096_bandai96.rs
index e059a9926..c52c68705 100644
--- a/crates/rustynes-mappers/src/m096_bandai96.rs
+++ b/crates/rustynes-mappers/src/m096_bandai96.rs
@@ -157,6 +157,15 @@ impl Mapper for Bandai96 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let masked = addr & 0x3FFF;
+ match masked {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(masked)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(masked, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let masked = addr & 0x3FFF;
if (self.last_ppu_addr & 0x3000) != 0x2000 && (masked & 0x3000) == 0x2000 {
diff --git a/crates/rustynes-mappers/src/m097_irem_tam_s1.rs b/crates/rustynes-mappers/src/m097_irem_tam_s1.rs
index 486ac6335..b8138ae77 100644
--- a/crates/rustynes-mappers/src/m097_irem_tam_s1.rs
+++ b/crates/rustynes-mappers/src/m097_irem_tam_s1.rs
@@ -142,6 +142,15 @@ impl Mapper for Irem97 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m099_vs_system.rs b/crates/rustynes-mappers/src/m099_vs_system.rs
index 3abecbe4e..5450c0e45 100644
--- a/crates/rustynes-mappers/src/m099_vs_system.rs
+++ b/crates/rustynes-mappers/src/m099_vs_system.rs
@@ -299,6 +299,15 @@ impl Mapper for VsSystem {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m105_nes_event.rs b/crates/rustynes-mappers/src/m105_nes_event.rs
index 61e9aa8e1..899a9c8c7 100644
--- a/crates/rustynes-mappers/src/m105_nes_event.rs
+++ b/crates/rustynes-mappers/src/m105_nes_event.rs
@@ -233,6 +233,15 @@ impl Mapper for NesEvent105 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr < 0x2000 {
+ self.chr_ram[usize::from(addr)]
+ } else {
+ self.mmc1.ppu_peek(addr)
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if addr < 0x2000 {
diff --git a/crates/rustynes-mappers/src/m107_magic_dragon107.rs b/crates/rustynes-mappers/src/m107_magic_dragon107.rs
index bcb49e935..ae6782003 100644
--- a/crates/rustynes-mappers/src/m107_magic_dragon107.rs
+++ b/crates/rustynes-mappers/src/m107_magic_dragon107.rs
@@ -117,6 +117,19 @@ impl Mapper for MagicDragon107 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m113_ave_nina006.rs b/crates/rustynes-mappers/src/m113_ave_nina006.rs
index 9043e0332..6c49a0c3f 100644
--- a/crates/rustynes-mappers/src/m113_ave_nina006.rs
+++ b/crates/rustynes-mappers/src/m113_ave_nina006.rs
@@ -132,6 +132,15 @@ impl Mapper for Nina006M113 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m116_somari.rs b/crates/rustynes-mappers/src/m116_somari.rs
new file mode 100644
index 000000000..c990f1da5
--- /dev/null
+++ b/crates/rustynes-mappers/src/m116_somari.rs
@@ -0,0 +1,478 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Mapper 116: Gouder's SOMARI-P board (the Huang-1 / Huang-2 ASIC and a
+//! PAL), which simulates three different mapper chips and switches between
+//! them at run time (v3.2.0).
+//!
+//! **Provenance.** Written from the vendored `INES_Mapper_116.md`, and from
+//! the pages of the three chips it simulates: `VRC2_and_VRC4.md` (the VRC2b
+//! wiring, A0/A1), `MMC3.md` and `MMC1.md`. The MMC3 and MMC1 are the
+//! project's own [`Mmc3`] and [`Mmc1`], used as register files; neither file
+//! carries a provenance header. No reference-emulator source was read.
+//!
+//! ## The board, per the page
+//!
+//! - **Supervisor register** at `$4100` (mask `$E100`, write-only):
+//! `.... .CMM`. `MM` selects the simulated chip (0: VRC2, 1: MMC3, 2 and 3:
+//! MMC1); `C` is CHR A18, an outer 256 KiB CHR bank that applies in every
+//! mode.
+//! - **Separate state per chip.** "All three simulated chips have their
+//! separate registers, so that when switching between modes ... the entire
+//! mapper state changes including PRG/CHR ROM switchable and fixed banks as
+//! well as IRQ state." So each chip keeps its own registers here, only the
+//! selected one receives `$8000-$FFFF` writes, and only the selected one
+//! drives the banks, the mirroring and the IRQ line.
+//! - **VRC2 (VRC2b wiring):** PRG select 0 at `$8xxx`, mirroring at `$9xxx`,
+//! PRG select 1 at `$Axxx` (the page: *Garō Densetsu Special* writes to
+//! mirrors of all three, so each answers in its whole 4 KiB); 1 KiB CHR
+//! selects at `$B000-$E003` decoded by A12 and A0/A1, low nibble on A0 = 0
+//! and the VRC2's four high bits on A0 = 1; `$C000-$FFFF` fixed to the last
+//! 16 KiB. "The VRC2 CHR-ROM registers are initialized on power-up to
+//! `$FF`", which *Somari*'s original release relies on.
+//! - **MMC3:** a stock MMC3, with its A12 IRQ counter.
+//! - **MMC1:** a stock MMC1. "When switching to MMC1 mode, the serial shift
+//! register is reset" (*Garō Densetsu Special* relies on it).
+//! - **Huang-2:** the MMC1 PRG bank register is "shifted by one bit to the
+//! left" relative to a normal MMC1. No submapper exists for it; the page's
+//! heuristic (PRG-ROM and CHR-ROM each exactly 128 KiB) selects it.
+//!
+//! ## Choices the page leaves open
+//!
+//! - The supervisor's power-on value is not given. It is 0 (VRC2 mode): the
+//! page's own note that *Somari* depends on the VRC2 CHR registers'
+//! power-up value only makes sense if the board starts in VRC2 mode.
+//! - The inactive chips keep receiving the A12 and CPU-cycle clocks (they are
+//! one ASIC); only their outputs are deselected.
+//! - The VRC2's `$6000-$6FFF` microwire latch is not modelled; nothing on the
+//! page suggests these games use it, and the board has no WRAM.
+//! - Submapper 3's reset-switched 5-in-1 (named on the page in one sentence,
+//! with no register description) is not modelled.
+
+#![allow(clippy::cast_possible_truncation, clippy::doc_markdown)]
+
+use crate::cartridge::Mirroring;
+use crate::m001_mmc1::Mmc1;
+use crate::m004_mmc3::{Mmc3, Mmc3Revision};
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
+use crate::mmc3_boards::mirror_bank;
+use alloc::string::ToString;
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_BANK_1K: usize = 0x0400;
+const VRAM_SIZE: usize = 0x0800;
+
+/// Save-state layout version for [`Somari116`].
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// The VRC2 CHR registers' power-up value (`INES_Mapper_116.md`).
+const VRC2_CHR_POWER_UP: u8 = 0xFF;
+
+/// Which chip the supervisor register selects.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Mode {
+ Vrc2,
+ Mmc3,
+ Mmc1,
+}
+
+/// Mapper 116 (SOMARI-P, Huang-1 / Huang-2). See the module docs.
+pub struct Somari116 {
+ prg_rom: Box<[u8]>,
+ /// CHR-ROM, or 8 KiB of CHR-RAM when the image has none.
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ /// The console's 2 KiB of nametable RAM, mapped by the active chip's
+ /// mirroring.
+ vram: Box<[u8]>,
+ /// The `$4100` supervisor register.
+ supervisor: u8,
+ /// The Huang-2 ASIC: the MMC1 PRG register is shifted left by one.
+ huang2: bool,
+ /// VRC2 PRG selects (`$8xxx`, `$Axxx`), 5 bits.
+ vrc2_prg: [u8; 2],
+ /// VRC2 mirroring (`$9xxx` bit 0: 0 vertical, 1 horizontal).
+ vrc2_mirroring: u8,
+ /// VRC2 1 KiB CHR selects, 8 bits each (4 low + the VRC2's 4 high).
+ vrc2_chr: [u8; 8],
+ /// The simulated MMC3: register file and A12 IRQ counter.
+ mmc3: Mmc3,
+ /// The simulated MMC1: serial port and register file.
+ mmc1: Mmc1,
+}
+
+impl Somari116 {
+ /// Construct the board. The Huang-2 is selected by the page's size
+ /// heuristic (PRG-ROM and CHR-ROM both 128 KiB).
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of
+ /// 16 KiB or CHR is not a multiple of 1 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 116 PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 116 CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ let huang2 = prg_rom.len() == 128 * 1024 && chr_rom.len() == 128 * 1024;
+ let chr_is_ram = chr_rom.is_empty();
+ let chr = if chr_is_ram {
+ vec![0u8; 8 * CHR_BANK_1K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ // The MMC1 is a register file only: it is handed a minimal ROM it
+ // never reads, and no PRG-RAM is mapped (the board has none).
+ let mmc1 = Mmc1::new(
+ vec![0u8; 2 * PRG_BANK_16K].into_boxed_slice(),
+ Box::new([]),
+ mirroring,
+ 0,
+ )?;
+ Ok(Self {
+ prg_rom,
+ chr,
+ chr_is_ram,
+ vram: vec![0u8; VRAM_SIZE].into_boxed_slice(),
+ supervisor: 0,
+ huang2,
+ vrc2_prg: [0; 2],
+ vrc2_mirroring: 0,
+ vrc2_chr: [VRC2_CHR_POWER_UP; 8],
+ mmc3: Mmc3::register_core(mirroring, Mmc3Revision::Sharp),
+ mmc1,
+ })
+ }
+
+ const fn mode(&self) -> Mode {
+ match self.supervisor & 3 {
+ 0 => Mode::Vrc2,
+ 1 => Mode::Mmc3,
+ _ => Mode::Mmc1,
+ }
+ }
+
+ /// The 8 KiB PRG bank at `addr` (`$8000-$FFFF`), before the mirror.
+ fn prg_bank(&self, addr: u16) -> usize {
+ let a13 = usize::from((addr >> 13) & 1);
+ match self.mode() {
+ Mode::Vrc2 => match addr & 0xE000 {
+ 0x8000 => usize::from(self.vrc2_prg[0] & 0x1F),
+ 0xA000 => usize::from(self.vrc2_prg[1] & 0x1F),
+ // The last 16 KiB: the chip's all-ones bank pair.
+ 0xC000 => 0x1E,
+ _ => 0x1F,
+ },
+ Mode::Mmc3 => usize::from(self.mmc3.prg_bank_raw(addr)),
+ Mode::Mmc1 => {
+ let (control, _, _, prg) = self.mmc1.registers();
+ // Huang-2: the game writes the bank one bit to the left.
+ let bank = usize::from(if self.huang2 { prg >> 1 } else { prg }) & 0x0F;
+ let a14 = usize::from((addr >> 14) & 1);
+ let bank16 = match (control >> 2) & 3 {
+ 0 | 1 => (bank & !1) | a14,
+ 2 if a14 == 0 => 0,
+ 3 if a14 == 1 => 0x0F,
+ _ => bank,
+ };
+ (bank16 << 1) | a13
+ }
+ }
+ }
+
+ /// The 1 KiB CHR bank at a pattern-table address, before the mirror,
+ /// with the supervisor's CHR A18 on top.
+ fn chr_bank(&self, addr: u16) -> usize {
+ let addr = addr & 0x1FFF;
+ let inner = match self.mode() {
+ Mode::Vrc2 => usize::from(self.vrc2_chr[usize::from(addr >> 10)]),
+ Mode::Mmc3 => self.mmc3.chr_bank_1k(addr) & 0xFF,
+ Mode::Mmc1 => {
+ let (control, chr0, chr1, _) = self.mmc1.registers();
+ let a12 = addr & 0x1000 != 0;
+ let bank4k = if control & 0x10 == 0 {
+ // 8 KiB mode: the low bit of CHR bank 0 is ignored.
+ (chr0 & 0x1E) | u8::from(a12)
+ } else if a12 {
+ chr1
+ } else {
+ chr0
+ };
+ (usize::from(bank4k & 0x1F) << 2) | usize::from((addr >> 10) & 3)
+ }
+ };
+ inner | (usize::from((self.supervisor >> 2) & 1) << 8)
+ }
+
+ fn chr_offset(&self, addr: u16) -> usize {
+ let bank = mirror_bank(self.chr_bank(addr), self.chr.len() / CHR_BANK_1K);
+ bank * CHR_BANK_1K + (usize::from(addr) & (CHR_BANK_1K - 1))
+ }
+
+ fn vram_offset(&self, addr: u16) -> usize {
+ usize::from(self.nametable_address(addr)) & (VRAM_SIZE - 1)
+ }
+
+ /// A VRC2 register write (`$8000-$FFFF` in VRC2 mode).
+ fn vrc2_write(&mut self, addr: u16, value: u8) {
+ match addr & 0xF000 {
+ 0x8000 => self.vrc2_prg[0] = value & 0x1F,
+ 0x9000 => self.vrc2_mirroring = value & 1,
+ 0xA000 => self.vrc2_prg[1] = value & 0x1F,
+ 0xB000..=0xE000 => {
+ // Two selects per 4 KiB page, picked by A1; A0 picks the
+ // nibble. "VRC2 only has 4 high bits of CHR select."
+ let page = usize::from((addr >> 12) - 0xB);
+ let slot = page * 2 + usize::from((addr >> 1) & 1);
+ let old = self.vrc2_chr[slot];
+ self.vrc2_chr[slot] = if addr & 1 == 0 {
+ (old & 0xF0) | (value & 0x0F)
+ } else {
+ (old & 0x0F) | ((value & 0x0F) << 4)
+ };
+ }
+ _ => {}
+ }
+ }
+}
+
+impl Mapper for Somari116 {
+ fn caps(&self) -> MapperCaps {
+ // The MMC3's A12 filter and the MMC1's consecutive-write check both
+ // count CPU cycles; the MMC3 is an IRQ source.
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ addr < 0x8000
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if addr < 0x8000 {
+ return 0;
+ }
+ let bank = mirror_bank(self.prg_bank(addr), self.prg_rom.len() / PRG_BANK_8K);
+ self.prg_rom[bank * PRG_BANK_8K + (usize::from(addr) & 0x1FFF)]
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match addr {
+ 0x4020..=0x5FFF if addr & 0xE100 == 0x4100 => {
+ let was_mmc1 = self.mode() == Mode::Mmc1;
+ self.supervisor = value & 0x07;
+ if !was_mmc1 && self.mode() == Mode::Mmc1 {
+ self.mmc1.reset_shift_register();
+ }
+ }
+ 0x8000..=0xFFFF => match self.mode() {
+ Mode::Vrc2 => self.vrc2_write(addr, value),
+ Mode::Mmc3 => self.mmc3.cpu_write(addr, value),
+ Mode::Mmc1 => self.mmc1.cpu_write(addr, value),
+ },
+ _ => {}
+ }
+ }
+
+ fn chr_phys(&self, addr: u16) -> Option {
+ if self.chr_is_ram {
+ None
+ } else {
+ u32::try_from(self.chr_offset(addr & 0x1FFF)).ok()
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.vram_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.vram_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF if self.chr_is_ram => {
+ let off = self.chr_offset(addr);
+ self.chr[off] = value;
+ }
+ 0x2000..=0x3EFF => {
+ let off = self.vram_offset(addr);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ match self.mode() {
+ Mode::Vrc2 if self.vrc2_mirroring == 0 => Mirroring::Vertical,
+ Mode::Vrc2 => Mirroring::Horizontal,
+ Mode::Mmc3 => self.mmc3.current_mirroring(),
+ Mode::Mmc1 => self.mmc1.current_mirroring(),
+ }
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ self.mmc3.notify_a12(level);
+ self.mmc1.notify_a12(level);
+ }
+
+ fn notify_a12_at_sub_dot(&mut self, level: bool, sub_dot: u8) {
+ self.mmc3.notify_a12_at_sub_dot(level, sub_dot);
+ self.mmc1.notify_a12(level);
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ self.mmc3.notify_cpu_cycle();
+ self.mmc1.notify_cpu_cycle();
+ }
+
+ fn irq_pending(&self) -> bool {
+ // Only the MMC3 has an IRQ, and only the selected chip drives the
+ // line.
+ self.mode() == Mode::Mmc3 && self.mmc3.irq_pending()
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.mmc3.irq_acknowledge();
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = match self.mode() {
+ Mode::Mmc3 => self.mmc3.debug_info(),
+ Mode::Mmc1 => self.mmc1.debug_info(),
+ Mode::Vrc2 => {
+ let mut info = MapperDebugInfo::default();
+ for (i, p) in self.vrc2_prg.iter().enumerate() {
+ info.prg_banks
+ .push((format!("PRG{i}"), format!("{p:#04x}")));
+ }
+ for (i, c) in self.vrc2_chr.iter().enumerate() {
+ info.chr_banks
+ .push((format!("CHR{i}"), format!("{c:#04x}")));
+ }
+ info
+ }
+ };
+ info.mapper_id = 116;
+ info.name = if self.huang2 {
+ "SOMARI-P Huang-2 (116)"
+ } else {
+ "SOMARI-P Huang-1 (116)"
+ }
+ .to_string();
+ info.mirroring = crate::mapper::mirroring_name(self.current_mirroring());
+ info.extra.push((
+ "supervisor".to_string(),
+ format!("{:#04x}", self.supervisor),
+ ));
+ info.extra
+ .push(("mode".to_string(), format!("{:?}", self.mode())));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mmc3 = self.mmc3.save_state();
+ let mmc1 = self.mmc1.save_state();
+ let chr = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out = Vec::with_capacity(HEAD + mmc3.len() + mmc1.len() + chr + VRAM_SIZE);
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.supervisor);
+ out.extend_from_slice(&self.vrc2_prg);
+ out.push(self.vrc2_mirroring);
+ out.extend_from_slice(&self.vrc2_chr);
+ out.extend_from_slice(&(mmc3.len() as u32).to_le_bytes());
+ out.extend_from_slice(&(mmc1.len() as u32).to_le_bytes());
+ out.extend_from_slice(&mmc3);
+ out.extend_from_slice(&mmc1);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ if data.len() < HEAD {
+ return Err(MapperError::WrongLength {
+ expected: HEAD,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ let len_at = |i: usize| {
+ u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize
+ };
+ let (mmc3_len, mmc1_len) = (len_at(13), len_at(17));
+ let chr = if self.chr_is_ram { self.chr.len() } else { 0 };
+ // Checked: both lengths come from the blob, and a plain sum can wrap
+ // on a 32-bit target, pass the check, and panic at the slice.
+ let expected = HEAD
+ .checked_add(mmc3_len)
+ .and_then(|n| n.checked_add(mmc1_len))
+ .and_then(|n| n.checked_add(chr + VRAM_SIZE));
+ if expected != Some(data.len()) {
+ return Err(MapperError::WrongLength {
+ expected: expected.unwrap_or(usize::MAX),
+ got: data.len(),
+ });
+ }
+ let mut cur = HEAD;
+ // The MMC3 part is restored first, so keep its old state: if the MMC1
+ // part is then refused, put it back, and the board is left exactly as
+ // it was rather than half restored (CodeRabbit on #608).
+ let mmc3_prev = self.mmc3.save_state();
+ self.mmc3.load_state(&data[cur..cur + mmc3_len])?;
+ cur += mmc3_len;
+ if let Err(e) = self.mmc1.load_state(&data[cur..cur + mmc1_len]) {
+ // Its own blob, just produced, so this restore cannot fail.
+ let _ = self.mmc3.load_state(&mmc3_prev);
+ return Err(e);
+ }
+ cur += mmc1_len;
+ self.supervisor = data[1] & 0x07;
+ self.vrc2_prg = [data[2] & 0x1F, data[3] & 0x1F];
+ self.vrc2_mirroring = data[4] & 1;
+ self.vrc2_chr.copy_from_slice(&data[5..13]);
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[cur..cur + chr]);
+ cur += chr;
+ }
+ self.vram.copy_from_slice(&data[cur..]);
+ Ok(())
+ }
+}
+
+/// Fixed head of the save state: version, supervisor, two VRC2 PRG selects,
+/// VRC2 mirroring, eight VRC2 CHR selects, and the two embedded chip states'
+/// lengths.
+const HEAD: usize = 1 + 1 + 2 + 1 + 8 + 4 + 4;
+
+#[cfg(test)]
+#[path = "m116_somari_tests.rs"]
+mod tests;
diff --git a/crates/rustynes-mappers/src/m116_somari_tests.rs b/crates/rustynes-mappers/src/m116_somari_tests.rs
new file mode 100644
index 000000000..54c272661
--- /dev/null
+++ b/crates/rustynes-mappers/src/m116_somari_tests.rs
@@ -0,0 +1,327 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Register-decode tests for `m116_somari.rs`, written from
+//! `INES_Mapper_116.md`, `VRC2_and_VRC4.md`, `MMC1.md` and `MMC3.md`.
+
+use alloc::vec::Vec;
+
+/// An iNES 1.0 image for mapper 116: every 8 KiB PRG bank and every 1 KiB
+/// CHR bank starts with its own bank number (low, high).
+fn ines_image(prg_16k: u8, chr_8k: u8) -> Vec {
+ let mut v = Vec::new();
+ v.extend_from_slice(b"NES\x1A");
+ v.push(prg_16k);
+ v.push(chr_8k);
+ v.push(0x40); // mapper low nibble 4
+ v.push(0x70); // mapper high nibble 7 -> 0x74 = 116
+ v.extend_from_slice(&[0u8; 8]);
+ for b in 0..usize::from(prg_16k) * 2 {
+ let mut bank = alloc::vec![0u8; 0x2000];
+ bank[0] = b as u8;
+ bank[1] = (b >> 8) as u8;
+ v.extend_from_slice(&bank);
+ }
+ for b in 0..usize::from(chr_8k) * 8 {
+ let mut bank = alloc::vec![0u8; 0x400];
+ bank[0] = b as u8;
+ bank[1] = (b >> 8) as u8;
+ v.extend_from_slice(&bank);
+ }
+ v
+}
+
+#[test]
+fn m116_parses() {
+ assert!(
+ crate::parse(&ines_image(16, 32)).is_ok(),
+ "mapper 116 must construct"
+ );
+}
+
+use super::*;
+
+fn prg_image(banks_8k: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
+ for b in 0..banks_8k {
+ v[b * PRG_BANK_8K] = b as u8;
+ v[b * PRG_BANK_8K + 1] = (b >> 8) as u8;
+ }
+ v.into_boxed_slice()
+}
+
+fn chr_image(banks_1k: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
+ for b in 0..banks_1k {
+ v[b * CHR_BANK_1K] = b as u8;
+ v[b * CHR_BANK_1K + 1] = (b >> 8) as u8;
+ }
+ v.into_boxed_slice()
+}
+
+/// 256 KiB PRG (32 x 8 KiB), 512 KiB CHR (512 x 1 KiB): Huang-1.
+fn board() -> Somari116 {
+ Somari116::new(prg_image(32), chr_image(512), Mirroring::Vertical).expect("valid")
+}
+
+fn prg_at(m: &mut Somari116, addr: u16) -> usize {
+ m.cpu_read(addr) as usize | (m.cpu_read(addr + 1) as usize) << 8
+}
+
+fn chr_at(m: &mut Somari116, addr: u16) -> usize {
+ m.ppu_read(addr) as usize | (m.ppu_read(addr + 1) as usize) << 8
+}
+
+/// The MMC1 serial port: five writes, LSB first.
+fn mmc1_write(m: &mut Somari116, addr: u16, value: u8) {
+ for i in 0..5 {
+ m.cpu_write(addr, (value >> i) & 1);
+ }
+}
+
+fn clock_a12(m: &mut Somari116) {
+ m.notify_a12(false);
+ for _ in 0..4 {
+ m.notify_cpu_cycle();
+ }
+ m.notify_a12(true);
+ m.notify_cpu_cycle();
+}
+
+/// The supervisor answers where `addr & $E100 == $4100`.
+#[test]
+fn m116_supervisor_decode() {
+ for (addr, hit) in [
+ (0x4100u16, true),
+ (0x41FF, true),
+ (0x5F00, true),
+ (0x4200, false),
+ (0x5000, false),
+ (0x6100, false),
+ ] {
+ let mut m = board();
+ m.cpu_write(addr, 0x01);
+ assert_eq!(m.mode() == Mode::Mmc3, hit, "{addr:#06x}");
+ }
+}
+
+/// Power-on is VRC2 mode with every CHR select at `$FF`.
+#[test]
+fn m116_powers_on_in_vrc2_mode_with_chr_ff() {
+ let mut m = board();
+ assert_eq!(m.mode(), Mode::Vrc2);
+ for slot in 0..8u16 {
+ assert_eq!(chr_at(&mut m, slot * 0x400), 0xFF, "slot {slot}");
+ }
+}
+
+/// VRC2b: PRG 0 at `$8xxx`, mirroring at `$9xxx`, PRG 1 at `$Axxx`, each in
+/// its whole 4 KiB (*Garō Densetsu Special* writes the mirrors); the last
+/// 16 KiB fixed.
+#[test]
+fn m116_vrc2_prg_and_mirroring_answer_in_their_whole_page() {
+ let mut m = board();
+ m.cpu_write(0x8FFF, 0x05);
+ m.cpu_write(0xA7A3, 0x09);
+ assert_eq!(prg_at(&mut m, 0x8000), 5);
+ assert_eq!(prg_at(&mut m, 0xA000), 9);
+ assert_eq!(prg_at(&mut m, 0xC000), 30);
+ assert_eq!(prg_at(&mut m, 0xE000), 31);
+ m.cpu_write(0x9ABC, 0x01);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0x9000, 0x02); // bit 1 ignored on the VRC2
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+}
+
+/// VRC2b CHR selects: A12 and A1 pick the select, A0 the nibble, and the
+/// VRC2 has only four high bits.
+#[test]
+fn m116_vrc2_chr_selects() {
+ let mut m = board();
+ for (lo, hi, window) in [
+ (0xB000u16, 0xB001u16, 0x0000u16),
+ (0xB002, 0xB003, 0x0400),
+ (0xC000, 0xC001, 0x0800),
+ (0xC002, 0xC003, 0x0C00),
+ (0xD000, 0xD001, 0x1000),
+ (0xD002, 0xD003, 0x1400),
+ (0xE000, 0xE001, 0x1800),
+ (0xE002, 0xE003, 0x1C00),
+ ] {
+ m.cpu_write(lo, 0x3A); // low nibble A
+ m.cpu_write(hi, 0x15); // bit 4 ignored: high nibble 5
+ assert_eq!(chr_at(&mut m, window), 0x5A, "{lo:#06x}/{hi:#06x}");
+ }
+}
+
+/// MMC3 mode: a stock MMC3 with its IRQ, which drives the line only while
+/// MMC3 mode is selected.
+#[test]
+fn m116_mmc3_mode_and_its_irq() {
+ let mut m = board();
+ m.cpu_write(0x4100, 0x01);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x07);
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 0x33);
+ assert_eq!(prg_at(&mut m, 0x8000), 7);
+ assert_eq!(prg_at(&mut m, 0xE000), 31);
+ assert_eq!(chr_at(&mut m, 0x1000), 0x33);
+ m.cpu_write(0xA000, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0xC000, 1);
+ m.cpu_write(0xC001, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ clock_a12(&mut m);
+ assert!(m.irq_pending());
+ m.cpu_write(0x4100, 0x00);
+ assert!(!m.irq_pending(), "VRC2 mode: the MMC3 is deselected");
+ m.cpu_write(0x4100, 0x01);
+ assert!(m.irq_pending(), "its IRQ state survives the switch");
+}
+
+/// "All three simulated chips have their separate registers": each chip's
+/// banks come back when it is selected again.
+#[test]
+fn m116_each_chip_keeps_its_own_registers() {
+ let mut m = board();
+ m.cpu_write(0x8000, 0x05); // VRC2 PRG 0
+ m.cpu_write(0x4100, 0x01);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x09); // MMC3 R6
+ m.cpu_write(0x4100, 0x02);
+ mmc1_write(&mut m, 0xE000, 0x03); // MMC1 PRG
+ assert_eq!(prg_at(&mut m, 0x8000), 6, "MMC1: 16 KiB bank 3");
+ m.cpu_write(0x4100, 0x00);
+ assert_eq!(prg_at(&mut m, 0x8000), 5, "VRC2 again");
+ m.cpu_write(0x4100, 0x01);
+ assert_eq!(prg_at(&mut m, 0x8000), 9, "MMC3 again");
+ m.cpu_write(0x4100, 0x03);
+ assert_eq!(prg_at(&mut m, 0x8000), 6, "mode 3 is MMC1 too");
+}
+
+/// MMC1 mode: PRG mode 3 (switch `$8000`, last bank at `$C000`), 4 KiB and
+/// 8 KiB CHR modes, and the control register's mirroring.
+#[test]
+fn m116_mmc1_mode_banks() {
+ let mut m = board();
+ m.cpu_write(0x4100, 0x02);
+ mmc1_write(&mut m, 0x8000, 0x1F); // control: PRG mode 3, CHR 4 KiB, H
+ mmc1_write(&mut m, 0xE000, 0x02);
+ assert_eq!(prg_at(&mut m, 0x8000), 4);
+ assert_eq!(prg_at(&mut m, 0xC000), 30);
+ mmc1_write(&mut m, 0xA000, 0x05);
+ mmc1_write(&mut m, 0xC000, 0x09);
+ assert_eq!(chr_at(&mut m, 0x0000), 5 * 4);
+ assert_eq!(chr_at(&mut m, 0x1C00), 9 * 4 + 3);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ mmc1_write(&mut m, 0x8000, 0x02); // PRG mode 0 (32 KiB), CHR 8 KiB, V
+ assert_eq!(prg_at(&mut m, 0x8000), 4);
+ assert_eq!(prg_at(&mut m, 0xC000), 6);
+ // 8 KiB mode: CHR 0's low bit is ignored, A12 picks the half:
+ // (5 & $1E) | 1 = 4 KiB bank 5 = 1 KiB bank 20 at `$1000`.
+ assert_eq!(chr_at(&mut m, 0x1000), 20);
+ assert_eq!(chr_at(&mut m, 0x0000), 16);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+}
+
+/// "When switching to MMC1 mode, the serial shift register is reset": three
+/// stray bits written before a switch away and back do not corrupt the next
+/// five-write sequence. Re-selecting MMC1 from MMC1 (mode 2 -> 3) is not a
+/// switch.
+#[test]
+fn m116_switching_to_mmc1_resets_the_shift_register() {
+ let mut m = board();
+ m.cpu_write(0x4100, 0x02);
+ for _ in 0..3 {
+ m.cpu_write(0xE000, 1);
+ }
+ m.cpu_write(0x4100, 0x01);
+ m.cpu_write(0x4100, 0x02);
+ mmc1_write(&mut m, 0xE000, 0x02);
+ assert_eq!(prg_at(&mut m, 0x8000), 4, "a clean five-write commit");
+
+ let mut m = board();
+ m.cpu_write(0x4100, 0x02);
+ for _ in 0..3 {
+ m.cpu_write(0xE000, 1);
+ }
+ m.cpu_write(0x4100, 0x03); // still MMC1: no reset
+ m.cpu_write(0xE000, 0);
+ m.cpu_write(0xE000, 0); // fifth bit: commits 0b00111
+ assert_eq!(prg_at(&mut m, 0x8000), 14, "the shift continued");
+}
+
+/// The supervisor's bit 2 is CHR A18 in every mode.
+#[test]
+fn m116_outer_chr_bank_in_every_mode() {
+ let mut m = board();
+ m.cpu_write(0xB000, 0x04);
+ m.cpu_write(0xB001, 0x00);
+ m.cpu_write(0x4100, 0x04);
+ assert_eq!(chr_at(&mut m, 0x0000), 0x104, "VRC2");
+ m.cpu_write(0x4100, 0x05);
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 0x21);
+ assert_eq!(chr_at(&mut m, 0x1000), 0x121, "MMC3");
+ m.cpu_write(0x4100, 0x06);
+ mmc1_write(&mut m, 0x8000, 0x1C);
+ mmc1_write(&mut m, 0xA000, 0x03);
+ assert_eq!(chr_at(&mut m, 0x0000), 0x100 + 12, "MMC1");
+}
+
+/// Huang-2 (PRG-ROM and CHR-ROM both 128 KiB): the MMC1 PRG register is
+/// written one bit to the left. Huang-1 is not.
+#[test]
+fn m116_huang2_shifts_the_mmc1_prg_register() {
+ let mut m = Somari116::new(prg_image(16), chr_image(128), Mirroring::Vertical).expect("valid");
+ assert!(m.huang2);
+ m.cpu_write(0x4100, 0x02);
+ mmc1_write(&mut m, 0xE000, 0x06);
+ assert_eq!(prg_at(&mut m, 0x8000), 6, "written 6 = bank 3");
+ let mut m = board();
+ assert!(!m.huang2);
+ m.cpu_write(0x4100, 0x02);
+ mmc1_write(&mut m, 0xE000, 0x06);
+ assert_eq!(prg_at(&mut m, 0x8000), 12, "written 6 = bank 6");
+}
+
+#[test]
+fn m116_state_round_trip() {
+ let mut m = board();
+ m.cpu_write(0x8000, 0x05);
+ m.cpu_write(0xB001, 0x03);
+ m.cpu_write(0x4100, 0x05);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x09);
+ m.ppu_write(0x2123, 0x44);
+ let blob = m.save_state();
+ let mut n = board();
+ n.load_state(&blob).expect("round trip");
+ assert_eq!(n.save_state(), blob);
+ assert_eq!(n.ppu_read(0x2123), 0x44);
+ let mut bad = blob.clone();
+ bad[13..17].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert!(board().load_state(&bad).is_err());
+}
+
+/// A state whose MMC1 part is refused must leave the board as it was: the MMC3
+/// part used to be restored first and kept when the MMC1 part then failed,
+/// mixing new MMC3 state with the old MMC1, VRC2 and RAM (`CodeRabbit` on #608).
+#[test]
+fn m116_a_refused_mmc1_part_leaves_the_mmc3_untouched() {
+ let mut src = board();
+ // MMC3 mode first: in the power-on VRC2 mode these writes never reach the
+ // MMC3, the two boards' MMC3 states would be equal, and the test could not
+ // see the bug (its first draft did exactly that; mutation found it).
+ src.cpu_write(0x4100, 0x01);
+ src.cpu_write(0x8000, 0x06); // MMC3 bank select: R6
+ src.cpu_write(0x8001, 0x03);
+ let mut bad = src.save_state();
+ let mmc3_len = u32::from_le_bytes([bad[13], bad[14], bad[15], bad[16]]) as usize;
+ bad[HEAD + mmc3_len] = 0xEE; // the MMC1 blob's version byte
+
+ let mut m = board();
+ let before = m.save_state();
+ assert!(m.load_state(&bad).is_err());
+ assert_eq!(m.save_state(), before, "nothing restored");
+}
diff --git a/crates/rustynes-mappers/src/m118_txsrom.rs b/crates/rustynes-mappers/src/m118_txsrom.rs
index 2ea0a1df8..712a29219 100644
--- a/crates/rustynes-mappers/src/m118_txsrom.rs
+++ b/crates/rustynes-mappers/src/m118_txsrom.rs
@@ -145,10 +145,18 @@ impl Mapper for TxSrom {
self.inner.cpu_read_unmapped(addr)
}
+ fn may_unmap_prg_rom(&self) -> bool {
+ self.inner.may_unmap_prg_rom()
+ }
+
fn ppu_read(&mut self, addr: u16) -> u8 {
self.inner.ppu_read(addr)
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ self.inner.ppu_peek(addr)
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
self.inner.ppu_write(addr, value);
}
diff --git a/crates/rustynes-mappers/src/m119_tqrom.rs b/crates/rustynes-mappers/src/m119_tqrom.rs
index 7dff3cbc6..448f50ae6 100644
--- a/crates/rustynes-mappers/src/m119_tqrom.rs
+++ b/crates/rustynes-mappers/src/m119_tqrom.rs
@@ -128,6 +128,10 @@ impl Mapper for Tqrom {
self.inner.cpu_read_unmapped(addr)
}
+ fn may_unmap_prg_rom(&self) -> bool {
+ self.inner.may_unmap_prg_rom()
+ }
+
fn ppu_read(&mut self, addr: u16) -> u8 {
let a = addr & 0x3FFF;
if a < 0x2000 {
@@ -147,6 +151,20 @@ impl Mapper for Tqrom {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let a = addr & 0x3FFF;
+ if a < 0x2000 {
+ let (is_ram, off) = self.resolve_chr(a);
+ if is_ram {
+ self.chr_ram[off % self.chr_ram.len()]
+ } else {
+ self.inner.ppu_peek(a)
+ }
+ } else {
+ self.inner.ppu_peek(a)
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let a = addr & 0x3FFF;
if a < 0x2000 {
diff --git a/crates/rustynes-mappers/src/m132_txc_22211.rs b/crates/rustynes-mappers/src/m132_txc_22211.rs
index 65904e7b0..7808f7b7d 100644
--- a/crates/rustynes-mappers/src/m132_txc_22211.rs
+++ b/crates/rustynes-mappers/src/m132_txc_22211.rs
@@ -210,6 +210,19 @@ impl Mapper for Txc132 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = ((self.txc.output() & 0x03) as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m136_sachen_3011.rs b/crates/rustynes-mappers/src/m136_sachen_3011.rs
index ea2f89c27..e4f6f747e 100644
--- a/crates/rustynes-mappers/src/m136_sachen_3011.rs
+++ b/crates/rustynes-mappers/src/m136_sachen_3011.rs
@@ -244,6 +244,18 @@ impl Mapper for Sachen3011 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let bank = (self.txc.output as usize) % self.chr_count_8k;
+ self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m151_konami_vs.rs b/crates/rustynes-mappers/src/m151_konami_vs.rs
index 34442a4f5..cef4c472a 100644
--- a/crates/rustynes-mappers/src/m151_konami_vs.rs
+++ b/crates/rustynes-mappers/src/m151_konami_vs.rs
@@ -66,6 +66,10 @@ impl Mapper for KonamiVs {
self.inner.ppu_read(addr)
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ self.inner.ppu_peek(addr)
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
self.inner.ppu_write(addr, value);
}
diff --git a/crates/rustynes-mappers/src/m152_bandai152.rs b/crates/rustynes-mappers/src/m152_bandai152.rs
index 786350688..0a3806582 100644
--- a/crates/rustynes-mappers/src/m152_bandai152.rs
+++ b/crates/rustynes-mappers/src/m152_bandai152.rs
@@ -150,6 +150,15 @@ impl Mapper for Bandai152 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m156_daou156.rs b/crates/rustynes-mappers/src/m156_daou156.rs
index 0c20ea7fc..01bfe22b2 100644
--- a/crates/rustynes-mappers/src/m156_daou156.rs
+++ b/crates/rustynes-mappers/src/m156_daou156.rs
@@ -190,6 +190,15 @@ impl Mapper for Daou156 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.read_chr(addr),
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
diff --git a/crates/rustynes-mappers/src/m163_nanjing.rs b/crates/rustynes-mappers/src/m163_nanjing.rs
index b6925e3e1..6342e0dcc 100644
--- a/crates/rustynes-mappers/src/m163_nanjing.rs
+++ b/crates/rustynes-mappers/src/m163_nanjing.rs
@@ -223,6 +223,15 @@ impl Mapper for Nanjing163 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr < 0x2000 {
+ self.chr_ram[self.chr_offset(addr)]
+ } else {
+ 0
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if addr < 0x2000 {
diff --git a/crates/rustynes-mappers/src/m172_m173_txc.rs b/crates/rustynes-mappers/src/m172_m173_txc.rs
new file mode 100644
index 000000000..a0bdcc45d
--- /dev/null
+++ b/crates/rustynes-mappers/src/m172_m173_txc.rs
@@ -0,0 +1,860 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Mappers 172 and 173: two copy-protected TXC / Idea-Tek boards built around
+//! a latch-adder-inverter ASIC (v3.2.0).
+//!
+//! Written from the vendored `NESdev` pages `INES_Mapper_172`,
+//! `INES_Mapper_173`, and the two pages they link for the chip itself,
+//! `INES_Mapper_136` (the JV001 register file mapper 172 shares) and
+//! `TXC_05_00002_010_pinout` (the 24-pin chip on mapper 173). No
+//! reference-emulator source was read. The project's mapper 132
+//! (`m132_txc_22211.rs`) and mapper 136 (`m136_sachen_3011.rs`) model
+//! relatives of these chips, but neither is reused: this module states the two
+//! register files exactly as their pages give them, so each bit can be checked
+//! against one sentence of documentation.
+//!
+//! ## The protection chip
+//!
+//! Both chips hold a small **register** that the game can load from an
+//! **input** latch (optionally inverted), increment in place, and read back
+//! through `$4100`. A write anywhere in `$8000-$FFFF` then copies the register
+//! into an **output** latch whose pins drive the CHR-ROM address lines. The
+//! value written to `$8000` is ignored. A game proves the chip is present by
+//! putting it through a sequence of loads, inversions and increments and then
+//! comparing what `$4100` returns; a board that simply latched the `$8000`
+//! data would fail that check, which is the copy protection.
+//!
+//! ### Mapper 172: JV001, mounted upside down (Super Mega P-4070)
+//!
+//! Five registers: Input (6 bits), Output (6 bits), Register (6 bits), Mode
+//! (1 bit) and Invert (1 bit), at mask `$E103`. The board connects CPU D0-D5 to
+//! the chip in **reverse order** ("the JV001 ASIC being mounted upside down",
+//! `INES_Mapper_136`), so every value crossing the data bus is bit-reversed.
+//! This model stores the chip's registers in the chip's own bit order and
+//! reverses at the bus:
+//!
+//! | access | effect (chip order) |
+//! |---|---|
+//! | read `$4100-$4103` | Register, bits 4-5 inverted when Invert = 1, reversed onto D0-D5; D6-D7 open bus |
+//! | write `$4100` | Mode 0: Register := Input, bits 0-3 inverted when Invert = 1. Mode 1: Register bits 0-3 += 1, bits 4-5 kept |
+//! | write `$4101` | Invert := CPU D5 |
+//! | write `$4102` | Input := CPU D0-D5, reversed |
+//! | write `$4103` | Mode := CPU D5 |
+//! | write `$8000-$FFFF` | Output := Register; mirroring := Horizontal if Invert = 0, Vertical if Invert = 1 |
+//!
+//! "Bits 0/1 of the 8 KiB CHR ROM bank number come from Output 5/4, or 0/1
+//! after taking the reverse bit order into account", so in chip order the
+//! 8 KiB CHR bank is `Output & 3`. PRG is a fixed 32 KiB. Unlike the other
+//! JV001 boards, mirroring is software-controlled, so the board reports
+//! [`Mapper::has_hardwired_mirroring`] `false`. Until the first `$8000` write
+//! the header's mirroring stands: the page says only what a write changes it to.
+//!
+//! ### Mapper 173: 05-00002-010 (Idea-Tek ET-xx)
+//!
+//! Six registers: "P", "R" and Output (three bits each); "S", inCrement and
+//! inVert (one bit each), at the CPU's own bit order:
+//!
+//! | access | effect |
+//! |---|---|
+//! | read `$4100` (mask `$E100`) | `[xxxx SRRR]`: RRR, and S XOR V on D3; D4-D7 open bus |
+//! | write `$4100` (mask `$E103`) | C set: RRR += 1. Else RRR := PPP, or ~PPP when V is set. S unchanged |
+//! | write `$4101` | V := D0 |
+//! | write `$4102` | S := D3, PPP := D0-D2 |
+//! | write `$4103` | C := D0 |
+//! | write `$8000-$FFFF` (mask `$8000`) | Output := the bottom two bits of RRR |
+//!
+//! "8 KiB CHR-ROM bank number := (Output &1) | (~Invert <<1)": CHR A13 is
+//! Output bit 0 and CHR A14 is the *inverse* of V, read live rather than
+//! latched, so a `$4101` write moves CHR A14 immediately. PRG is a fixed 32 KiB
+//! (16 KiB on *Xiao Ma Li*, mirrored). Mirroring is hard-wired.
+//!
+//! *Xiao Ma Li* replaces the 32 KiB CHR-ROM with an 8 KiB EPROM whose pin 27 is
+//! `/PGM` instead of A14, and "setting Invert on this board just disables
+//! CHR-ROM". The page does not say what the PPU then reads, so that is not
+//! modelled: an 8 KiB CHR image simply wraps every bank onto its one bank.
+//!
+//! The TXC re-release of *Mahjong Block* (headerless CRC32 `0ACFC3CD`) "is
+//! commonly set to mapper 173, but since it expects Output bit 1 to select CHR
+//! A14, it actually uses INES Mapper 132". It is a mapper 132 board with a
+//! mapper 173 header, and is not emulated as 173 here.
+
+#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_32K: usize = 0x8000;
+const CHR_BANK_8K: usize = 0x2000;
+const NAMETABLE_SIZE: usize = 0x0400;
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// Reverse bits 0-5 of `v` (bits 6-7 are dropped): the mapper 172 board wires
+/// CPU D0-D5 to the JV001's D5-D0.
+const fn reverse6(v: u8) -> u8 {
+ let mut out = 0;
+ let mut i = 0;
+ while i < 6 {
+ if v & (1 << i) != 0 {
+ out |= 1 << (5 - i);
+ }
+ i += 1;
+ }
+ out
+}
+
+/// The chip's register window: `$4100-$5FFF` addresses whose `$E100` bits read
+/// `$4100`. Both pages decode the read with `$E100` (mapper 172's "Read
+/// `$4100-$4103`" under mask `$E103` names all four low addresses, so A0-A1
+/// are don't-care there too).
+const fn in_read_window(addr: u16) -> bool {
+ addr & 0xE100 == 0x4100
+}
+
+/// Validate the shared ROM geometry: PRG a non-zero multiple of 16 KiB (one or
+/// two 16 KiB halves of the fixed 32 KiB window) and non-empty CHR-ROM in
+/// 8 KiB banks.
+fn check_geometry(id: u16, prg: &[u8], chr: &[u8]) -> Result<(), MapperError> {
+ if prg.is_empty() || !prg.len().is_multiple_of(0x4000) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {id} PRG-ROM size {} is not a non-zero multiple of 16 KiB",
+ prg.len()
+ )));
+ }
+ if chr.is_empty() || !chr.len().is_multiple_of(CHR_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {id} CHR-ROM size {} is not a non-zero multiple of 8 KiB",
+ chr.len()
+ )));
+ }
+ Ok(())
+}
+
+/// The fixed 32 KiB PRG window. A larger image keeps its first 32 KiB (neither
+/// board has a PRG bank line); a 16 KiB image is mirrored.
+fn prg_byte(prg: &[u8], addr: u16) -> u8 {
+ let off = (usize::from(addr) - 0x8000) % PRG_BANK_32K.min(prg.len());
+ prg[off]
+}
+
+/// The save-state tag for a mirroring, below the `0x80` marker bit. H and V
+/// are 0 and 1, the values the first encoding wrote (`0x80` / `0x81`), so
+/// every earlier blob still reads the same. That encoding saved only "vertical
+/// or not": `new` keeps the header's mirroring until the first `$8000` write,
+/// and a four-screen header is not refused by the parser, so such a dump came
+/// back Horizontal after a load and its nametables moved (`CodeRabbit` on #608).
+const fn mirroring_tag(m: Mirroring) -> u8 {
+ match m {
+ Mirroring::Horizontal => 0,
+ Mirroring::Vertical => 1,
+ Mirroring::SingleScreenA => 2,
+ Mirroring::SingleScreenB => 3,
+ Mirroring::FourScreen => 4,
+ Mirroring::MapperControlled => 5,
+ }
+}
+
+const fn mirroring_from_tag(t: u8) -> Option {
+ Some(match t {
+ 0 => Mirroring::Horizontal,
+ 1 => Mirroring::Vertical,
+ 2 => Mirroring::SingleScreenA,
+ 3 => Mirroring::SingleScreenB,
+ 4 => Mirroring::FourScreen,
+ 5 => Mirroring::MapperControlled,
+ _ => return None,
+ })
+}
+
+fn nametable_offset(addr: u16, mirroring: Mirroring) -> usize {
+ let table = (((addr - 0x2000) / 0x0400) & 0x03) as u8;
+ mirroring.physical_bank(table) * NAMETABLE_SIZE + (usize::from(addr) & (NAMETABLE_SIZE - 1))
+}
+
+// ---------------------------------------------------------------------------
+// Mapper 172
+// ---------------------------------------------------------------------------
+
+/// Mapper 172: the Super Mega P-4070 board (*1991 Du Ma Racing*, the original
+/// *Mahjong Block*, *Venice Beach Volley*). See the module docs.
+pub struct Txc172 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// The six-bit Input latch, chip bit order.
+ input: u8,
+ /// The six-bit Register, chip bit order.
+ register: u8,
+ /// The six-bit Output latch, chip bit order.
+ output: u8,
+ /// Mode: `true` makes a `$4100` write increment instead of load.
+ increment: bool,
+ /// Invert.
+ invert: bool,
+ /// The mirroring the last `$8000-$FFFF` write chose (the header's until then).
+ mirroring: Mirroring,
+}
+
+impl Txc172 {
+ /// Construct the board.
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 16 KiB
+ /// or CHR-ROM is empty or not a multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_geometry(172, &prg_rom, &chr_rom)?;
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ input: 0,
+ register: 0,
+ output: 0,
+ increment: false,
+ invert: false,
+ mirroring,
+ })
+ }
+
+ /// The value the chip drives for a `$4100` read, in chip bit order:
+ /// Register with bits 4-5 inverted while Invert is set.
+ fn chip_read(&self) -> u8 {
+ self.register ^ if self.invert { 0x30 } else { 0 }
+ }
+
+ fn chr_bank(&self) -> usize {
+ usize::from(self.output & 0x03) % (self.chr_rom.len() / CHR_BANK_8K)
+ }
+}
+
+impl Mapper for Txc172 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ matches!(addr, 0x4020..=0x7FFF) && !in_read_window(addr)
+ }
+
+ /// D6-D7 of a `$4100` read are open bus.
+ fn cpu_read_driven_mask(&self, addr: u16) -> u8 {
+ if in_read_window(addr) { 0x3F } else { 0xFF }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => prg_byte(&self.prg_rom, addr),
+ _ if in_read_window(addr) => reverse6(self.chip_read()),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if addr >= 0x8000 {
+ // The written value is ignored.
+ self.output = self.register;
+ self.mirroring = if self.invert {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ };
+ return;
+ }
+ match addr & 0xE103 {
+ 0x4100 => {
+ self.register = if self.increment {
+ (self.register & 0x30) | (self.register.wrapping_add(1) & 0x0F)
+ } else {
+ self.input ^ if self.invert { 0x0F } else { 0 }
+ };
+ }
+ // CPU D5 is chip D0 on this board.
+ 0x4101 => self.invert = value & 0x20 != 0,
+ 0x4102 => self.input = reverse6(value),
+ 0x4103 => self.increment = value & 0x20 != 0,
+ _ => {}
+ }
+ }
+
+ fn chr_phys(&self, addr: u16) -> Option {
+ u32::try_from(self.chr_bank() * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok()
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ if let 0x2000..=0x3EFF = addr {
+ self.vram[nametable_offset(addr, self.mirroring)] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = MapperDebugInfo {
+ mapper_id: 172,
+ name: "TXC JV001 reversed (172)".into(),
+ mirroring: mirroring_name(self.mirroring),
+ ..Default::default()
+ };
+ info.chr_banks
+ .push(("8K".into(), format!("{:#04x}", self.chr_bank())));
+ info.extra.push((
+ "register".into(),
+ format!("{:#04x} (chip order)", self.register),
+ ));
+ info.extra
+ .push(("input".into(), format!("{:#04x}", self.input)));
+ info.extra
+ .push(("output".into(), format!("{:#04x}", self.output)));
+ info.extra.push((
+ "mode/invert".into(),
+ format!("{}/{}", u8::from(self.increment), u8::from(self.invert)),
+ ));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(7 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.input);
+ out.push(self.register);
+ out.push(self.output);
+ out.push(u8::from(self.increment));
+ out.push(u8::from(self.invert));
+ out.push(mirroring_tag(self.mirroring) | 0x80);
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 7 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::WrongLength {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ // Decoded before any field changes, so an unknown tag leaves the board
+ // as it was.
+ let mirroring = mirroring_from_tag(data[6] & 0x7F)
+ .ok_or_else(|| MapperError::Invalid(format!("TXC mirroring tag {:#04x}", data[6])))?;
+ self.input = data[1] & 0x3F;
+ self.register = data[2] & 0x3F;
+ self.output = data[3] & 0x3F;
+ self.increment = data[4] != 0;
+ self.invert = data[5] != 0;
+ self.mirroring = mirroring;
+ self.vram.copy_from_slice(&data[7..]);
+ Ok(())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mapper 173
+// ---------------------------------------------------------------------------
+
+/// Mapper 173: Idea-Tek's unmarked ET-xx board (*Puzzle*, *Xiao Ma Li*,
+/// *F-15 City War*, *Poker Jingling*, *Zhanguo Sichuan Sheng*). See the module
+/// docs.
+pub struct Txc173 {
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ vram: Box<[u8]>,
+ mirroring: Mirroring,
+ /// "PPP": three bits loaded from `$4102`.
+ p: u8,
+ /// "RRR": three bits, loaded or incremented by a `$4100` write.
+ r: u8,
+ /// "S": one bit from `$4102` D3, read back immediately (XOR V).
+ s: bool,
+ /// Output: the bottom two bits of RRR at the last `$8000-$FFFF` write.
+ output: u8,
+ /// "C", increment mode.
+ increment: bool,
+ /// "V", invert mode; its inverse is CHR A14.
+ invert: bool,
+}
+
+impl Txc173 {
+ /// Construct the board. Mirroring is hard-wired, so the header's stands.
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 16 KiB
+ /// or CHR-ROM is empty or not a multiple of 8 KiB.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ check_geometry(173, &prg_rom, &chr_rom)?;
+ Ok(Self {
+ prg_rom,
+ chr_rom,
+ vram: vec![0u8; 2 * NAMETABLE_SIZE].into_boxed_slice(),
+ mirroring,
+ p: 0,
+ r: 0,
+ s: false,
+ output: 0,
+ increment: false,
+ invert: false,
+ })
+ }
+
+ /// `[xxxx SRRR]`.
+ fn chip_read(&self) -> u8 {
+ (self.r & 0x07) | (u8::from(self.s ^ self.invert) << 3)
+ }
+
+ /// `(Output & 1) | (~Invert << 1)`.
+ fn chr_bank(&self) -> usize {
+ let bank = usize::from(self.output & 0x01) | (usize::from(!self.invert) << 1);
+ bank % (self.chr_rom.len() / CHR_BANK_8K)
+ }
+}
+
+impl Mapper for Txc173 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ fn has_hardwired_mirroring(&self) -> bool {
+ true
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ matches!(addr, 0x4020..=0x7FFF) && !in_read_window(addr)
+ }
+
+ /// D4-D7 of a `$4100` read are open bus.
+ fn cpu_read_driven_mask(&self, addr: u16) -> u8 {
+ if in_read_window(addr) { 0x0F } else { 0xFF }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => prg_byte(&self.prg_rom, addr),
+ _ if in_read_window(addr) => self.chip_read(),
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if addr >= 0x8000 {
+ self.output = self.r & 0x03;
+ return;
+ }
+ match addr & 0xE103 {
+ 0x4100 => {
+ self.r = if self.increment {
+ self.r.wrapping_add(1) & 0x07
+ } else if self.invert {
+ !self.p & 0x07
+ } else {
+ self.p
+ };
+ }
+ 0x4101 => self.invert = value & 0x01 != 0,
+ 0x4102 => {
+ self.s = value & 0x08 != 0;
+ self.p = value & 0x07;
+ }
+ 0x4103 => self.increment = value & 0x01 != 0,
+ _ => {}
+ }
+ }
+
+ fn chr_phys(&self, addr: u16) -> Option {
+ u32::try_from(self.chr_bank() * CHR_BANK_8K + usize::from(addr & 0x1FFF)).ok()
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_bank() * CHR_BANK_8K + usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ if let 0x2000..=0x3EFF = addr {
+ self.vram[nametable_offset(addr, self.mirroring)] = value;
+ }
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = MapperDebugInfo {
+ mapper_id: 173,
+ name: "TXC 05-00002-010 (173)".into(),
+ mirroring: mirroring_name(self.mirroring),
+ ..Default::default()
+ };
+ info.chr_banks
+ .push(("8K".into(), format!("{:#04x}", self.chr_bank())));
+ info.extra.push((
+ "P/R/S".into(),
+ format!("{}/{}/{}", self.p, self.r, u8::from(self.s)),
+ ));
+ info.extra.push((
+ "C/V".into(),
+ format!("{}/{}", u8::from(self.increment), u8::from(self.invert)),
+ ));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(7 + self.vram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.p);
+ out.push(self.r);
+ out.push(u8::from(self.s));
+ out.push(self.output);
+ out.push(u8::from(self.increment));
+ out.push(u8::from(self.invert));
+ out.extend_from_slice(&self.vram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 7 + self.vram.len();
+ if data.len() != expected {
+ return Err(MapperError::WrongLength {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.p = data[1] & 0x07;
+ self.r = data[2] & 0x07;
+ self.s = data[3] != 0;
+ self.output = data[4] & 0x03;
+ self.increment = data[5] != 0;
+ self.invert = data[6] != 0;
+ self.vram.copy_from_slice(&data[7..]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parse;
+
+ /// An iNES 1.0 image for `mapper`: `prg_16k` x 16 KiB PRG whose every
+ /// 16 KiB half opens with its index, and `chr_8k` x 8 KiB CHR whose every
+ /// bank opens with its index. Vertical mirroring in the header.
+ fn image(mapper: u8, prg_16k: usize, chr_8k: usize) -> Vec {
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = prg_16k as u8;
+ v[5] = chr_8k as u8;
+ v[6] = ((mapper & 0x0F) << 4) | 0x01;
+ v[7] = mapper & 0xF0;
+ for b in 0..prg_16k {
+ let mut bank = vec![0u8; 0x4000];
+ bank[0] = b as u8;
+ v.extend_from_slice(&bank);
+ }
+ for b in 0..chr_8k {
+ let mut bank = vec![0u8; CHR_BANK_8K];
+ bank[0] = b as u8;
+ v.extend_from_slice(&bank);
+ }
+ v
+ }
+
+ /// Build through `crate::parse`, so the dispatch arm is under test too.
+ fn board(mapper: u8, prg_16k: usize, chr_8k: usize) -> Box {
+ parse(&image(mapper, prg_16k, chr_8k))
+ .unwrap_or_else(|e| panic!("mapper {mapper} must parse: {e:?}"))
+ .1
+ }
+
+ #[test]
+ fn reverse6_reverses_d0_to_d5() {
+ assert_eq!(reverse6(0b00_0001), 0b10_0000);
+ assert_eq!(reverse6(0b00_0011), 0b11_0000);
+ assert_eq!(reverse6(0b10_1100), 0b00_1101);
+ assert_eq!(reverse6(0xC0), 0, "D6-D7 are not connected");
+ }
+
+ // --- 172 ---------------------------------------------------------------
+
+ #[test]
+ fn m172_parses_with_fixed_32k_prg_and_header_mirroring_until_8000() {
+ let mut m = board(172, 2, 4);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 1, "fixed 32 KiB, no PRG banking");
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical, "header");
+ assert!(!m.has_hardwired_mirroring(), "software-controlled");
+ }
+
+ #[test]
+ fn m172_load_and_read_back_are_bit_reversed() {
+ let mut m = board(172, 2, 4);
+ // CPU $01 is chip bit 5; CPU $08 is chip bit 2.
+ m.cpu_write(0x4102, 0x09);
+ m.cpu_write(0x4100, 0); // Mode 0, Invert 0: Register := Input.
+ assert_eq!(m.cpu_read(0x4100), 0x09, "reversed in, reversed out");
+ assert_eq!(m.cpu_read(0x4103), 0x09, "all of $4100-$4103 read");
+ assert_eq!(m.cpu_read(0x5F01), 0x09, "mask $E100");
+ assert_eq!(m.cpu_read_driven_mask(0x4100), 0x3F, "D6-D7 open bus");
+ assert!(!m.cpu_read_unmapped(0x4100));
+ assert!(m.cpu_read_unmapped(0x4000 + 0x20), "outside the mask");
+ assert!(m.cpu_read_unmapped(0x4200));
+ assert!(m.cpu_read_unmapped(0x6100), "A13 set is not the chip");
+ }
+
+ #[test]
+ fn m172_invert_flips_loaded_bits_0_3_and_read_bits_4_5() {
+ let mut m = board(172, 2, 4);
+ m.cpu_write(0x4101, 0x20); // CPU D5 = chip D0: Invert := 1.
+ m.cpu_write(0x4102, 0x00); // Input := 0.
+ m.cpu_write(0x4100, 0); // Register := Input ^ 0x0F (chip order).
+ // Chip register 0x0F; read inverts chip bits 4-5 -> 0x3F; reversed
+ // onto the bus -> 0x3F.
+ assert_eq!(m.cpu_read(0x4100), 0x3F);
+ m.cpu_write(0x4101, 0x00); // Invert := 0.
+ // Chip 0x0F reversed: chip bits 0-3 are CPU bits 5-2 -> 0x3C.
+ assert_eq!(m.cpu_read(0x4100), 0x3C);
+ m.cpu_write(0x4101, 0x01); // CPU D0 is chip D5: not Invert.
+ assert_eq!(m.cpu_read(0x4100), 0x3C, "Invert is CPU D5 only");
+ }
+
+ #[test]
+ fn m172_increment_wraps_bits_0_3_and_keeps_4_5() {
+ let mut m = board(172, 2, 4);
+ // Chip register 0x3F: load chip Input 0x3F (CPU 0x3F reversed).
+ m.cpu_write(0x4102, 0x3F);
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x4103, 0x20); // Mode := 1 (CPU D5).
+ m.cpu_write(0x4100, 0); // Bits 0-3: 0xF + 1 -> 0x0; bits 4-5 kept.
+ // Chip 0x30 reversed -> CPU 0x03.
+ assert_eq!(m.cpu_read(0x4100), 0x03);
+ m.cpu_write(0x4100, 0);
+ // Chip 0x31 reversed -> CPU 0x23.
+ assert_eq!(m.cpu_read(0x4100), 0x23);
+ m.cpu_write(0x4103, 0x01); // CPU D0 is not Mode: back to load.
+ m.cpu_write(0x4100, 0);
+ assert_eq!(m.cpu_read(0x4100), 0x3F, "Mode 0 reloads Input");
+ }
+
+ #[test]
+ fn m172_8000_latches_output_into_chr_and_sets_mirroring_from_invert() {
+ let mut m = board(172, 2, 4);
+ // Chip register 0x02 (CHR bank 2): CPU 0x10 reversed is chip 0x02.
+ m.cpu_write(0x4102, 0x10);
+ m.cpu_write(0x4100, 0);
+ assert_eq!(m.ppu_read(0x0000), 0, "Output not yet latched");
+ m.cpu_write(0x8000, 0xFF); // The written value is ignored.
+ assert_eq!(m.ppu_read(0x0000), 2);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal, "Invert 0");
+ // Chip 0x01 = CPU 0x20: CHR bank 1. Invert set before the latch.
+ m.cpu_write(0x4102, 0x20);
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x4101, 0x20);
+ m.cpu_write(0xFFFF, 0x00);
+ assert_eq!(m.ppu_read(0x1FFF - 0x1FFF), 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical, "Invert 1");
+ // Chip bits 2-5 of Output do not reach CHR (32 KiB, A13-A14 only).
+ m.cpu_write(0x4101, 0);
+ m.cpu_write(0x4102, 0x01); // chip 0x20
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.ppu_read(0x0000), 0);
+ }
+
+ #[test]
+ fn m172_state_round_trips() {
+ let mut a = board(172, 2, 4);
+ a.cpu_write(0x4102, 0x2A);
+ a.cpu_write(0x4101, 0x20);
+ a.cpu_write(0x4100, 0);
+ a.cpu_write(0x8000, 0);
+ a.cpu_write(0x4103, 0x20);
+ a.ppu_write(0x2005, 0x77);
+ let blob = a.save_state();
+ let mut b = board(172, 2, 4);
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.cpu_read(0x4100), a.cpu_read(0x4100));
+ assert_eq!(b.ppu_read(0x0000), a.ppu_read(0x0000));
+ assert_eq!(b.current_mirroring(), a.current_mirroring());
+ assert!(b.load_state(&blob[1..]).is_err());
+ }
+
+ /// A four-screen header's mirroring survives a save and load (it came
+ /// back Horizontal when the blob stored only "vertical or not"), an old
+ /// `0x81` blob still reads as Vertical, and an unknown tag is refused with
+ /// the board unchanged.
+ #[test]
+ fn m172_state_keeps_a_four_screen_header_and_refuses_unknown_tags() {
+ let mut img = image(172, 2, 4);
+ img[6] |= 0x08; // four-screen
+ let a = parse(&img).unwrap().1;
+ assert_eq!(a.current_mirroring(), Mirroring::FourScreen, "header");
+ let blob = a.save_state();
+ let mut b = board(172, 2, 4);
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.current_mirroring(), Mirroring::FourScreen);
+
+ let mut old = blob.clone();
+ old[6] = 0x81;
+ b.load_state(&old).unwrap();
+ assert_eq!(b.current_mirroring(), Mirroring::Vertical);
+
+ let mut bad = blob;
+ bad[6] = 0x86; // tags 0-5 are the six mirrorings
+ let before = b.save_state();
+ assert!(b.load_state(&bad).is_err());
+ assert_eq!(b.save_state(), before, "nothing restored");
+ }
+
+ // --- 173 ---------------------------------------------------------------
+
+ #[test]
+ fn m173_parses_with_hardwired_mirroring_and_mirrors_16k_prg() {
+ let mut m = board(173, 1, 1);
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 0, "16 KiB mirrored");
+ assert!(m.has_hardwired_mirroring());
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ let mut m = board(173, 2, 4);
+ assert_eq!(m.cpu_read(0xC000), 1, "32 KiB fixed");
+ }
+
+ #[test]
+ fn m173_read_is_s_xor_v_over_rrr_with_d4_d7_open() {
+ let mut m = board(173, 2, 4);
+ m.cpu_write(0x4102, 0x0D); // S = 1, PPP = 5.
+ assert_eq!(
+ m.cpu_read(0x4100),
+ 0x08,
+ "S reads back at once, RRR not yet"
+ );
+ m.cpu_write(0x4100, 0); // RRR := PPP.
+ assert_eq!(m.cpu_read(0x4100), 0x0D);
+ assert_eq!(m.cpu_read(0x41FF), 0x0D, "mask $E100");
+ assert_eq!(m.cpu_read_driven_mask(0x4100), 0x0F);
+ m.cpu_write(0x4101, 0x01); // V = 1: S XOR V = 0.
+ assert_eq!(m.cpu_read(0x4100), 0x05, "RRR itself is not inverted");
+ assert!(m.cpu_read_unmapped(0x4000 + 0x80));
+ assert!(m.cpu_read_unmapped(0x7100));
+ }
+
+ #[test]
+ fn m173_load_inverts_ppp_under_v_and_increment_wraps_three_bits() {
+ let mut m = board(173, 2, 4);
+ m.cpu_write(0x4102, 0x02);
+ m.cpu_write(0x4101, 0x01);
+ m.cpu_write(0x4100, 0);
+ assert_eq!(m.cpu_read(0x4100) & 0x07, 0x05, "~PPP");
+ m.cpu_write(0x4103, 0x01); // C = 1.
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x4100, 0);
+ assert_eq!(m.cpu_read(0x4100) & 0x07, 0x00, "5 + 3 wraps in 3 bits");
+ // Mask $E103: A2-A7 are not decoded, so $4104 is $4100; an address
+ // with A8 clear ($4020) is not the chip.
+ m.cpu_write(0x4104, 0);
+ assert_eq!(m.cpu_read(0x4100) & 0x07, 0x01, "$4104 mirrors $4100");
+ m.cpu_write(0x4020, 0);
+ assert_eq!(m.cpu_read(0x4100) & 0x07, 0x01, "$4020 is not decoded");
+ }
+
+ #[test]
+ fn m173_chr_is_output_bit_0_and_inverse_v() {
+ let mut m = board(173, 2, 4);
+ // V = 0: CHR A14 = 1 -> banks 2/3.
+ m.cpu_write(0x4102, 0x01);
+ m.cpu_write(0x4100, 0);
+ assert_eq!(m.ppu_read(0x0000), 2, "Output 0, ~V = 1");
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.ppu_read(0x0000), 3, "Output bit 0 = 1");
+ // V moves CHR A14 at once, without a $8000 latch.
+ m.cpu_write(0x4101, 0x01);
+ assert_eq!(m.ppu_read(0x0000), 1, "~V = 0");
+ // Output bit 1 does not reach CHR on this board.
+ m.cpu_write(0x4101, 0x00);
+ m.cpu_write(0x4102, 0x02);
+ m.cpu_write(0x4100, 0);
+ m.cpu_write(0x8000, 0);
+ assert_eq!(m.ppu_read(0x0000), 2, "RRR = 2: Output bit 0 clear");
+ }
+
+ #[test]
+ fn m173_state_round_trips() {
+ let mut a = board(173, 2, 4);
+ a.cpu_write(0x4102, 0x0B);
+ a.cpu_write(0x4100, 0);
+ a.cpu_write(0x8000, 0);
+ a.cpu_write(0x4103, 0x01);
+ a.cpu_write(0x4101, 0x01);
+ a.ppu_write(0x2400, 0x12);
+ let blob = a.save_state();
+ let mut b = board(173, 2, 4);
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.cpu_read(0x4100), a.cpu_read(0x4100));
+ assert_eq!(b.ppu_read(0x0000), a.ppu_read(0x0000));
+ assert_eq!(b.ppu_read(0x2400), 0x12);
+ }
+
+ #[test]
+ fn rejects_bad_geometry() {
+ assert!(Txc172::new(vec![0; 0x4000].into(), Box::default(), Mirroring::Vertical).is_err());
+ assert!(
+ Txc173::new(
+ vec![0; 0x1000].into(),
+ vec![0; 0x2000].into(),
+ Mirroring::Vertical
+ )
+ .is_err()
+ );
+ }
+}
diff --git a/crates/rustynes-mappers/src/m176_bmc_fk23c.rs b/crates/rustynes-mappers/src/m176_bmc_fk23c.rs
index 33f07c0ec..e7f3e0a22 100644
--- a/crates/rustynes-mappers/src/m176_bmc_fk23c.rs
+++ b/crates/rustynes-mappers/src/m176_bmc_fk23c.rs
@@ -573,6 +573,31 @@ impl Mapper for Fk23c {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ // The FS005 mixed-memory overlay outranks the flat CHR window.
+ // `select_chr_ram` is `$5xx0.5`, which on submapper 2 selects
+ // NROM CHR mode rather than the SFC-12B CHR-RAM window, so a
+ // game can have both set -- and if the flat path won, `ppu_write`
+ // would keep writing the overlay while `ppu_read` could never
+ // see it. Resolve the bank first and let the overlay answer.
+ let slot = (addr as usize) / CHR_BANK_1K;
+ let b = self.resolve_chr(slot);
+ if self.chr_bank_is_ram(b) {
+ return self.chr_ram[b * CHR_BANK_1K + (addr as usize & 0x3FF)];
+ }
+ if self.chr_is_ram || self.select_chr_ram {
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ self.chr[b * CHR_BANK_1K + (addr as usize & 0x3FF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m177_hengedianzi.rs b/crates/rustynes-mappers/src/m177_hengedianzi.rs
index 081c2bf7b..98246dbed 100644
--- a/crates/rustynes-mappers/src/m177_hengedianzi.rs
+++ b/crates/rustynes-mappers/src/m177_hengedianzi.rs
@@ -142,6 +142,15 @@ impl Mapper for Hengedianzi177 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m179_hengedianzi.rs b/crates/rustynes-mappers/src/m179_hengedianzi.rs
index 43bb03e95..6e1552fe9 100644
--- a/crates/rustynes-mappers/src/m179_hengedianzi.rs
+++ b/crates/rustynes-mappers/src/m179_hengedianzi.rs
@@ -119,6 +119,15 @@ impl Mapper for Hengedianzi179 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m180_nichibutsu180.rs b/crates/rustynes-mappers/src/m180_nichibutsu180.rs
index b5c51c383..1d196f9c7 100644
--- a/crates/rustynes-mappers/src/m180_nichibutsu180.rs
+++ b/crates/rustynes-mappers/src/m180_nichibutsu180.rs
@@ -132,6 +132,15 @@ impl Mapper for Nichibutsu180 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m184_sunsoft1.rs b/crates/rustynes-mappers/src/m184_sunsoft1.rs
index 49547fcc6..e2c5092b0 100644
--- a/crates/rustynes-mappers/src/m184_sunsoft1.rs
+++ b/crates/rustynes-mappers/src/m184_sunsoft1.rs
@@ -136,6 +136,15 @@ impl Mapper for Sunsoft1 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m185_cnrom185.rs b/crates/rustynes-mappers/src/m185_cnrom185.rs
index e796e44dd..9f616cd00 100644
--- a/crates/rustynes-mappers/src/m185_cnrom185.rs
+++ b/crates/rustynes-mappers/src/m185_cnrom185.rs
@@ -167,6 +167,25 @@ impl Mapper for CnRom185 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_enabled {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ } else {
+ // CHR disabled by protection: the open bus reads $FF (D0 is
+ // held high by a pull-up, which $FF already satisfies).
+ 0xFF
+ }
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m210_namco175.rs b/crates/rustynes-mappers/src/m210_namco175.rs
index 1a91abec6..dee80483c 100644
--- a/crates/rustynes-mappers/src/m210_namco175.rs
+++ b/crates/rustynes-mappers/src/m210_namco175.rs
@@ -232,6 +232,18 @@ impl Mapper for Namco175 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let off = self.chr_offset(addr);
+ self.chr_rom[off % self.chr_rom.len()]
+ }
+ 0x2000..=0x3EFF => self.vram[self.nametable_offset(addr) % self.vram.len()],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m228_action52.rs b/crates/rustynes-mappers/src/m228_action52.rs
index 75ef31dfc..e8390c7e8 100644
--- a/crates/rustynes-mappers/src/m228_action52.rs
+++ b/crates/rustynes-mappers/src/m228_action52.rs
@@ -147,6 +147,11 @@ impl Mapper for Action52M228 {
self.data = 0;
}
+ fn may_unmap_prg_rom(&self) -> bool {
+ // The three-chip image's chip 2 is absent: `$8000-$FFFF` floats.
+ true
+ }
+
fn cpu_read_unmapped(&self, addr: u16) -> bool {
match addr {
0x4020..=0x7FFF => true,
@@ -186,6 +191,15 @@ impl Mapper for Action52M228 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[self.chr_bank() * CHR_BANK_8K + usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m230_reset_multicart.rs b/crates/rustynes-mappers/src/m230_reset_multicart.rs
new file mode 100644
index 000000000..015bb4857
--- /dev/null
+++ b/crates/rustynes-mappers/src/m230_reset_multicart.rs
@@ -0,0 +1,334 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Mapper 230: the reset-switched *22-in-1* multicart (v3.2.0).
+//!
+//! Written from the vendored `NESdev` page `INES_Mapper_230`, which carries
+//! Disch's notes. The cartridge holds
+//! two PRG chips back to back in the image: chip 0, 128 KiB, is *Contra*;
+//! chip 1, 512 KiB, is the multicart. "Performing a Soft Reset switches
+//! between them" -- the console's RESET button is the mode switch, delivered
+//! to the board through [`Mapper::reset`].
+//!
+//! ```text
+//! Contra mode $8000-$FFFF: [.... .PPP] 16 KiB P at $8000, chip-0 page 7
+//! fixed at $C000; mirroring vertical
+//! Multicart mode $8000-$FFFF: [.MOP PPPP] M: mirroring (0 = H, 1 = V)
+//! O: PRG mode (0 = 32 KiB at
+//! P >> 1, 1 = 16 KiB P twice)
+//! P: 16 KiB page in chip 1
+//! ```
+//!
+//! CHR is 8 KiB of CHR-RAM. No IRQ.
+//!
+//! **What the notes leave open, and the choices made:**
+//!
+//! - *Power-on mode.* The notes say only that a reset toggles it. This board
+//! powers on in Contra mode, chip 0 being the first chip in the image; a
+//! power cycle rebuilds the mapper, so it starts there again.
+//! - *The register across a toggle.* "A soft reset changes which chip is used
+//! as well as other stuff relating to the mode", with no detail, so a reset
+//! clears the register to 0 in the new mode.
+//! - *Mirroring field polarity in Contra mode.* "Mirroring is always Vert in
+//! Contra Mode", so the register's bit 6 is ignored there.
+//! - *O polarity.* The PRG-setup table lists "Multi Mode 0" as one 32 KiB
+//! window and "Multi Mode 1" as two 16 KiB windows holding the same page.
+//! - *Image layout.* "chip 0 = 128k PRG (offset 0x00010-0x2000F), chip 1 =
+//! 512k PRG (offset 0x20010-0xA000F)": the first 128 KiB of PRG, then the
+//! next 512 KiB. Other image sizes wrap each chip's page into its own region.
+
+#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const CHR_RAM_8K: usize = 0x2000;
+/// Chip 0 (*Contra*) is the first 128 KiB of the image.
+const CHIP0_LEN: usize = 128 * 1024;
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// Mapper 230 (22-in-1, reset-switched). See the module docs.
+pub struct ResetMulticart230 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// `true` in multicart mode (chip 1), `false` in Contra mode (chip 0).
+ multicart: bool,
+ /// The last `$8000-$FFFF` write.
+ reg: u8,
+}
+
+impl ResetMulticart230 {
+ /// Construct the board in Contra mode.
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when PRG is not larger than the 128 KiB Contra
+ /// chip and a multiple of 16 KiB, or CHR-ROM is present (the board has
+ /// CHR-RAM only).
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: &[u8]) -> Result {
+ if prg_rom.len() <= CHIP0_LEN || !prg_rom.len().is_multiple_of(PRG_BANK_16K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 230 PRG-ROM size {} is not 128 KiB of Contra plus a multicart chip",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.is_empty() {
+ return Err(MapperError::Invalid(
+ "mapper 230 has CHR-RAM only; this image carries CHR-ROM".into(),
+ ));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_RAM_8K].into_boxed_slice(),
+ vram: vec![0u8; 0x800].into_boxed_slice(),
+ multicart: false,
+ reg: 0,
+ })
+ }
+
+ /// The image offset of the 16 KiB page `page` of the current chip.
+ fn page_base(&self, page: usize) -> usize {
+ if self.multicart {
+ let chip1_pages = (self.prg_rom.len() - CHIP0_LEN) / PRG_BANK_16K;
+ CHIP0_LEN + (page % chip1_pages) * PRG_BANK_16K
+ } else {
+ (page % (CHIP0_LEN / PRG_BANK_16K)) * PRG_BANK_16K
+ }
+ }
+
+ /// The 16 KiB page mapped at `addr` (`$8000-$FFFF`).
+ fn page_at(&self, addr: u16) -> usize {
+ let upper = addr >= 0xC000;
+ if self.multicart {
+ let p = usize::from(self.reg & 0x1F);
+ if self.reg & 0x20 != 0 {
+ p // O = 1: the same 16 KiB page in both halves
+ } else {
+ (p & !1) | usize::from(upper) // O = 0: 32 KiB at P >> 1
+ }
+ } else if upper {
+ 7 // Contra: chip-0 page 7 fixed at $C000
+ } else {
+ usize::from(self.reg & 0x07)
+ }
+ }
+
+ fn mirroring(&self) -> Mirroring {
+ if self.multicart && self.reg & 0x40 == 0 {
+ Mirroring::Horizontal
+ } else {
+ Mirroring::Vertical
+ }
+ }
+
+ fn nt_offset(&self, addr: u16) -> usize {
+ let table = ((addr - 0x2000) / 0x400) & 0x03;
+ self.mirroring().physical_bank(table as u8) * 0x400 + (usize::from(addr) & 0x3FF)
+ }
+}
+
+impl Mapper for ResetMulticart230 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ /// The console RESET toggles between Contra and the multicart and clears
+ /// the register (see the module docs).
+ fn reset(&mut self) {
+ self.multicart = !self.multicart;
+ self.reg = 0;
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if addr >= 0x8000 {
+ self.prg_rom[self.page_base(self.page_at(addr)) + (usize::from(addr) & 0x3FFF)]
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ if addr >= 0x8000 {
+ self.reg = value;
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)] = value,
+ 0x2000..=0x3EFF => {
+ let off = self.nt_offset(addr);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn nametable_address(&self, addr: u16) -> u16 {
+ u16::try_from(self.nt_offset(addr)).unwrap_or(0)
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring()
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = MapperDebugInfo {
+ mapper_id: 230,
+ name: "22-in-1 reset multicart (230)".into(),
+ mirroring: mirroring_name(self.mirroring()),
+ ..Default::default()
+ };
+ info.prg_banks.push((
+ "mode".into(),
+ if self.multicart {
+ "multicart"
+ } else {
+ "Contra"
+ }
+ .into(),
+ ));
+ info.prg_banks
+ .push(("$8000".into(), format!("{:#04x}", self.page_at(0x8000))));
+ info.prg_banks
+ .push(("$C000".into(), format!("{:#04x}", self.page_at(0xC000))));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(u8::from(self.multicart));
+ out.push(self.reg);
+ out.extend_from_slice(&self.vram);
+ out.extend_from_slice(&self.chr_ram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::WrongLength {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.multicart = data[1] != 0;
+ self.reg = data[2];
+ let v = 3 + self.vram.len();
+ self.vram.copy_from_slice(&data[3..v]);
+ self.chr_ram.copy_from_slice(&data[v..]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parse;
+
+ /// The 22-in-1 geometry through `crate::parse`: 640 KiB PRG (Contra 128 +
+ /// multicart 512), CHR-RAM. Every 16 KiB page opens with its image index.
+ fn board() -> Box {
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = 40;
+ v[5] = 0;
+ v[6] = 0x61; // 230 = 0xE6: low nibble 6, vertical
+ v[7] = 0xE0;
+ for p in 0..40u8 {
+ let mut page = vec![0u8; PRG_BANK_16K];
+ page[0] = p;
+ v.extend_from_slice(&page);
+ }
+ parse(&v)
+ .unwrap_or_else(|e| panic!("mapper 230 must parse: {e:?}"))
+ .1
+ }
+
+ #[test]
+ fn m230_powers_on_in_contra_mode_with_page_7_fixed() {
+ let mut m = board();
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(m.cpu_read(0xC000), 7, "chip-0 page 7 fixed");
+ m.cpu_write(0x8000, 0xFD); // [.... .PPP]: only 3 bits
+ assert_eq!(m.cpu_read(0x8000), 5);
+ assert_eq!(m.cpu_read(0xC000), 7);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical, "always V");
+ }
+
+ #[test]
+ fn m230_reset_switches_to_the_multicart_chip_and_back() {
+ let mut m = board();
+ m.cpu_write(0x8000, 0x03);
+ m.reset();
+ // Register cleared, O = 0: 32 KiB at chip-1 pages 0/1 = image 8/9.
+ assert_eq!(m.cpu_read(0x8000), 8);
+ assert_eq!(m.cpu_read(0xC000), 9);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal, "M = 0");
+ m.reset();
+ assert_eq!(m.cpu_read(0x8000), 0, "back to Contra, register cleared");
+ assert_eq!(m.cpu_read(0xC000), 7);
+ }
+
+ #[test]
+ fn m230_multicart_register_decodes_m_o_p() {
+ let mut m = board();
+ m.reset();
+ m.cpu_write(0x8000, 0x05); // O = 0, P = 5: 32 KiB pages 4/5
+ assert_eq!(m.cpu_read(0x8000), 8 + 4);
+ assert_eq!(m.cpu_read(0xC000), 8 + 5);
+ m.cpu_write(0xFFFF, 0x20 | 0x05); // O = 1: page 5 twice
+ assert_eq!(m.cpu_read(0x8000), 8 + 5);
+ assert_eq!(m.cpu_read(0xC000), 8 + 5);
+ m.cpu_write(0x8000, 0x40 | 0x1F); // M = 1, P = 31: last chip-1 pair
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical);
+ assert_eq!(m.cpu_read(0x8000), 8 + 30);
+ assert_eq!(m.cpu_read(0xC000), 8 + 31);
+ }
+
+ #[test]
+ fn m230_chr_ram_and_state_round_trip() {
+ let mut a = board();
+ a.reset();
+ a.cpu_write(0x8000, 0x26);
+ a.ppu_write(0x0123, 0x77);
+ a.ppu_write(0x2400, 0x11);
+ let blob = a.save_state();
+ let mut b = board();
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.cpu_read(0x8000), a.cpu_read(0x8000));
+ assert_eq!(b.ppu_read(0x0123), 0x77);
+ assert_eq!(b.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m230_rejects_chr_rom_and_a_contra_only_image() {
+ assert!(ResetMulticart230::new(vec![0; 0x20000].into(), &[]).is_err());
+ assert!(ResetMulticart230::new(vec![0; 0xA0000].into(), &[0; 0x2000]).is_err());
+ }
+}
diff --git a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs
index b22fdac9e..fb6d0dee3 100644
--- a/crates/rustynes-mappers/src/m232_camerica_bf9096.rs
+++ b/crates/rustynes-mappers/src/m232_camerica_bf9096.rs
@@ -127,6 +127,15 @@ impl Mapper for Camerica232 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m235_golden_game.rs b/crates/rustynes-mappers/src/m235_golden_game.rs
new file mode 100644
index 000000000..4c75147c7
--- /dev/null
+++ b/crates/rustynes-mappers/src/m235_golden_game.rs
@@ -0,0 +1,383 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Mapper 235: the "Golden Game" 150-in-1 / 260-in-1 address-latch multicart
+//! (v3.2.0).
+//!
+//! Written from the vendored `NESdev` page `INES_Mapper_235` (The Mad
+//! Dumper's notes plus the page's own description of the four TTL chips); no
+//! reference-emulator source was read. Like other multicarts, "the address
+//! written to is used instead of the actual data written":
+//!
+//! ```text
+//! A15 A0
+//! 1xMP RNBB xxxA AAAA
+//! || |||| | +++++- 32 KiB page within the selected 1 MiB ROM
+//! || ||++----------- B: ROM socket (A8 = bit 0, A9 = bit 1)
+//! || |+------------- N: 1 = one-screen (CIRAM A10 held low: 1scA)
+//! || +-------------- R: 0 = 32 KiB at $8000, 1 = one 16 KiB half twice
+//! |+---------------- P: which 16 KiB half when R = 1
+//! +----------------- M: mirroring, 0 = horizontal, 1 = vertical
+//! ```
+//!
+//! The page's chip description fixes the details: a 74'153 mux picks PRG A14
+//! from CPU A14 (R = 0) or P (R = 1), and a second mux picks CIRAM A10 from
+//! PPU A11 (M = 0) or PPU A10 (M = 1) with N as its enable, so N forces page
+//! A. "All eleven bits of the register seem to be cleared on reset", so the
+//! console RESET ([`Mapper::reset`]) clears it, as does power-on. CHR is 8 KiB
+//! of CHR-RAM. No IRQ.
+//!
+//! **Sockets and the image.** The board has four 1 MiB ROM sockets; the
+//! page's cartridge has two filled and says the valid selections are "ROM 0
+//! and ROM 2 (i.e.: 00b = ROM 0, 10b = ROM 1)": socket 2 holds the second
+//! chip. An image stores the filled chips in order, so this model maps:
+//!
+//! - a 4 MiB image: all four sockets, B indexing the image's 1 MiB quarters;
+//! - a 2 MiB image: sockets 0 and 2 (the page's cartridge), sockets 1 and 3
+//! empty;
+//! - 1 MiB or less: socket 0 only, the image wrapped within it.
+//!
+//! An empty socket drives nothing, so the CPU reads open bus there.
+
+#![allow(clippy::cast_possible_truncation, clippy::missing_const_for_fn)]
+
+use crate::cartridge::Mirroring;
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError, mirroring_name};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_16K: usize = 0x4000;
+const PRG_BANK_32K: usize = 0x8000;
+const SOCKET: usize = 1024 * 1024;
+const CHR_RAM_8K: usize = 0x2000;
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// Mapper 235 ("Golden Game" multicart). See the module docs.
+pub struct GoldenGame235 {
+ prg_rom: Box<[u8]>,
+ chr_ram: Box<[u8]>,
+ vram: Box<[u8]>,
+ /// The latched write address (A0-A13; A14 is don't-care, A15 always 1).
+ reg: u16,
+}
+
+impl GoldenGame235 {
+ /// Construct the board with the register cleared.
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 32 KiB
+ /// or CHR-ROM is present (the board has CHR-RAM only).
+ pub fn new(prg_rom: Box<[u8]>, chr_rom: &[u8]) -> Result {
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_32K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper 235 PRG-ROM size {} is not a non-zero multiple of 32 KiB",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.is_empty() {
+ return Err(MapperError::Invalid(
+ "mapper 235 has CHR-RAM only; this image carries CHR-ROM".into(),
+ ));
+ }
+ Ok(Self {
+ prg_rom,
+ chr_ram: vec![0u8; CHR_RAM_8K].into_boxed_slice(),
+ vram: vec![0u8; 0x800].into_boxed_slice(),
+ reg: 0,
+ })
+ }
+
+ /// The image's 1 MiB chip behind socket `b`, or `None` for an empty one.
+ fn chip(&self, b: usize) -> Option {
+ match self.prg_rom.len() {
+ l if l >= 4 * SOCKET => Some(b),
+ l if l >= 2 * SOCKET => b.is_multiple_of(2).then_some(b / 2),
+ _ => (b == 0).then_some(0),
+ }
+ }
+
+ /// The image offset for CPU `addr`, or `None` when the socket is empty.
+ fn prg_offset(&self, addr: u16) -> Option {
+ let b = usize::from((self.reg >> 8) & 0x03);
+ let chip = self.chip(b)?;
+ let page = usize::from(self.reg & 0x1F);
+ let a14 = if self.reg & 0x0800 != 0 {
+ usize::from(self.reg & 0x1000 != 0) // R = 1: P
+ } else {
+ usize::from(addr & 0x4000 != 0) // R = 0: CPU A14
+ };
+ let off = chip * SOCKET + page * PRG_BANK_32K + a14 * PRG_BANK_16K;
+ Some(off % self.prg_rom.len() + (usize::from(addr) & 0x3FFF))
+ }
+
+ fn mirroring(&self) -> Mirroring {
+ if self.reg & 0x0400 != 0 {
+ Mirroring::SingleScreenA
+ } else if self.reg & 0x2000 != 0 {
+ Mirroring::Vertical
+ } else {
+ Mirroring::Horizontal
+ }
+ }
+
+ fn nt_offset(&self, addr: u16) -> usize {
+ let table = ((addr - 0x2000) / 0x400) & 0x03;
+ self.mirroring().physical_bank(table as u8) * 0x400 + (usize::from(addr) & 0x3FF)
+ }
+}
+
+impl Mapper for GoldenGame235 {
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::NONE
+ }
+
+ /// "All eleven bits of the register seem to be cleared on reset."
+ fn reset(&mut self) {
+ self.reg = 0;
+ }
+
+ fn may_unmap_prg_rom(&self) -> bool {
+ // An image smaller than 4 MiB leaves sockets empty: they float.
+ true
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ match addr {
+ 0x4020..=0x7FFF => true,
+ 0x8000..=0xFFFF => self.prg_offset(addr).is_none(),
+ _ => false,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ if addr >= 0x8000 {
+ self.prg_offset(addr).map_or(0, |o| self.prg_rom[o])
+ } else {
+ 0
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, _value: u8) {
+ if addr >= 0x8000 {
+ self.reg = addr & 0x3FFF;
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)],
+ 0x2000..=0x3EFF => self.vram[self.nt_offset(addr)],
+ _ => 0,
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[usize::from(addr)] = value,
+ 0x2000..=0x3EFF => {
+ let off = self.nt_offset(addr);
+ self.vram[off] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn nametable_address(&self, addr: u16) -> u16 {
+ u16::try_from(self.nt_offset(addr)).unwrap_or(0)
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.mirroring()
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = MapperDebugInfo {
+ mapper_id: 235,
+ name: "Golden Game multicart (235)".into(),
+ mirroring: mirroring_name(self.mirroring()),
+ ..Default::default()
+ };
+ info.prg_banks
+ .push(("socket".into(), format!("{}", (self.reg >> 8) & 0x03)));
+ info.prg_banks
+ .push(("page".into(), format!("{:#04x}", self.reg & 0x1F)));
+ info.prg_banks.push((
+ "16K".into(),
+ if self.reg & 0x0800 != 0 {
+ if self.reg & 0x1000 != 0 {
+ "upper"
+ } else {
+ "lower"
+ }
+ } else {
+ "no (32 KiB)"
+ }
+ .into(),
+ ));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let mut out = Vec::with_capacity(3 + self.vram.len() + self.chr_ram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.reg.to_le_bytes());
+ out.extend_from_slice(&self.vram);
+ out.extend_from_slice(&self.chr_ram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let expected = 3 + self.vram.len() + self.chr_ram.len();
+ if data.len() != expected {
+ return Err(MapperError::WrongLength {
+ expected,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ self.reg = u16::from_le_bytes([data[1], data[2]]) & 0x3FFF;
+ let v = 3 + self.vram.len();
+ self.vram.copy_from_slice(&data[3..v]);
+ self.chr_ram.copy_from_slice(&data[v..]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parse;
+
+ /// A mapper 235 image through `crate::parse` with `mib` MiB of PRG (NES
+ /// 2.0, so 4 MiB is expressible); every 16 KiB page opens with its index
+ /// low byte and the next byte with its index high byte.
+ fn board(mib: usize) -> Box {
+ let pages = mib * 64;
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = (pages & 0xFF) as u8;
+ v[5] = 0;
+ v[6] = 0xB0; // 235 = 0xEB: low nibble 0xB, horizontal
+ v[7] = 0xE8; // high nibble 0xE, NES 2.0
+ v[9] = (pages >> 8) as u8; // PRG size MSB
+ for p in 0..pages {
+ let mut page = vec![0u8; PRG_BANK_16K];
+ page[0] = p as u8;
+ page[1] = (p >> 8) as u8;
+ v.extend_from_slice(&page);
+ }
+ parse(&v)
+ .unwrap_or_else(|e| panic!("mapper 235 must parse: {e:?}"))
+ .1
+ }
+
+ fn page_at(m: &mut dyn Mapper, addr: u16) -> usize {
+ usize::from(m.cpu_read(addr)) | (usize::from(m.cpu_read(addr + 1)) << 8)
+ }
+
+ /// The register address for the named fields (the page's letters).
+ #[allow(clippy::fn_params_excessive_bools, clippy::many_single_char_names)]
+ fn reg(m: bool, p: bool, r: bool, n: bool, b: u16, a: u16) -> u16 {
+ 0x8000
+ | (u16::from(m) << 13)
+ | (u16::from(p) << 12)
+ | (u16::from(r) << 11)
+ | (u16::from(n) << 10)
+ | (b << 8)
+ | a
+ }
+
+ #[test]
+ fn m235_32k_and_16k_modes_from_the_address() {
+ let mut m = board(4);
+ assert_eq!(page_at(m.as_mut(), 0x8000), 0, "power-on: page 0");
+ assert_eq!(page_at(m.as_mut(), 0xC000), 1);
+ m.cpu_write(reg(false, false, false, false, 0, 5), 0xFF);
+ assert_eq!(page_at(m.as_mut(), 0x8000), 10, "32 KiB page 5");
+ assert_eq!(page_at(m.as_mut(), 0xC000), 11);
+ m.cpu_write(reg(false, true, true, false, 0, 5), 0);
+ assert_eq!(page_at(m.as_mut(), 0x8000), 11, "R = 1, P = 1: upper half");
+ assert_eq!(page_at(m.as_mut(), 0xC000), 11, "twice");
+ m.cpu_write(reg(false, false, true, false, 0, 5), 0);
+ assert_eq!(page_at(m.as_mut(), 0xC000), 10, "P = 0: lower half");
+ // A14 of the write address is don't-care.
+ m.cpu_write(reg(false, false, false, false, 0, 3) | 0x4000, 0);
+ assert_eq!(page_at(m.as_mut(), 0x8000), 6);
+ }
+
+ #[test]
+ fn m235_sockets_on_4_2_and_1_mib_images() {
+ let mut m = board(4);
+ m.cpu_write(reg(false, false, false, false, 3, 1), 0);
+ assert_eq!(page_at(m.as_mut(), 0x8000), 3 * 64 + 2, "4 MiB: socket 3");
+ let mut m = board(2);
+ m.cpu_write(reg(false, false, false, false, 2, 1), 0);
+ assert_eq!(
+ page_at(m.as_mut(), 0x8000),
+ 64 + 2,
+ "2 MiB: socket 2 = chip 1"
+ );
+ m.cpu_write(reg(false, false, false, false, 1, 1), 0);
+ assert!(m.cpu_read_unmapped(0x8000), "socket 1 empty: open bus");
+ assert!(m.cpu_read_unmapped(0xFFFC));
+ let mut m = board(1);
+ m.cpu_write(reg(false, false, false, false, 2, 0), 0);
+ assert!(m.cpu_read_unmapped(0x8000), "1 MiB: only socket 0");
+ m.cpu_write(reg(false, false, false, false, 0, 0), 0);
+ assert!(!m.cpu_read_unmapped(0x8000));
+ }
+
+ #[test]
+ fn m235_mirroring_m_and_one_screen_n() {
+ let mut m = board(2);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal, "M = 0");
+ m.cpu_write(reg(true, false, false, false, 0, 0), 0);
+ assert_eq!(m.current_mirroring(), Mirroring::Vertical, "M = 1");
+ m.cpu_write(reg(true, false, false, true, 0, 0), 0);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA, "N wins");
+ m.cpu_write(reg(false, false, false, true, 0, 0), 0);
+ assert_eq!(m.current_mirroring(), Mirroring::SingleScreenA);
+ }
+
+ #[test]
+ fn m235_reset_clears_the_register() {
+ let mut m = board(2);
+ m.cpu_write(reg(true, true, true, true, 2, 9), 0);
+ m.reset();
+ assert_eq!(page_at(m.as_mut(), 0x8000), 0);
+ assert_eq!(page_at(m.as_mut(), 0xC000), 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ }
+
+ #[test]
+ fn m235_chr_ram_and_state_round_trip() {
+ let mut a = board(2);
+ a.cpu_write(reg(true, false, false, false, 2, 7), 0);
+ a.ppu_write(0x1FFF, 0x3C);
+ a.ppu_write(0x2C00, 0x5D);
+ let blob = a.save_state();
+ let mut b = board(2);
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(page_at(b.as_mut(), 0x8000), page_at(a.as_mut(), 0x8000));
+ assert_eq!(b.ppu_read(0x1FFF), 0x3C);
+ assert_eq!(b.ppu_read(0x2C00), 0x5D);
+ }
+
+ #[test]
+ fn m235_rejects_chr_rom_and_odd_prg() {
+ assert!(GoldenGame235::new(vec![0; 0x4000].into(), &[]).is_err());
+ assert!(GoldenGame235::new(vec![0; 0x8000].into(), &[0; 0x2000]).is_err());
+ }
+}
diff --git a/crates/rustynes-mappers/src/m240_cne_multicart.rs b/crates/rustynes-mappers/src/m240_cne_multicart.rs
index 1352cc747..f87c84966 100644
--- a/crates/rustynes-mappers/src/m240_cne_multicart.rs
+++ b/crates/rustynes-mappers/src/m240_cne_multicart.rs
@@ -128,6 +128,19 @@ impl Mapper for Cne240 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m241_bxrom241.rs b/crates/rustynes-mappers/src/m241_bxrom241.rs
index 712c1e343..cd5735999 100644
--- a/crates/rustynes-mappers/src/m241_bxrom241.rs
+++ b/crates/rustynes-mappers/src/m241_bxrom241.rs
@@ -153,6 +153,15 @@ impl Mapper for Bxrom241 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m244_cne_decathlon.rs b/crates/rustynes-mappers/src/m244_cne_decathlon.rs
index 76a314c53..b5a352458 100644
--- a/crates/rustynes-mappers/src/m244_cne_decathlon.rs
+++ b/crates/rustynes-mappers/src/m244_cne_decathlon.rs
@@ -151,6 +151,19 @@ impl Mapper for Decathlon244 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
diff --git a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs
index e40d758ec..4b7f5adcb 100644
--- a/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs
+++ b/crates/rustynes-mappers/src/m246_fong_shen_bang246.rs
@@ -162,6 +162,20 @@ impl Mapper for FongShenBang246 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let slot = (addr >> 11) as usize & 0x03;
+ let count = (self.chr_rom.len() / CHR_BANK_2K).max(1);
+ let bank = (self.chr_banks[slot] as usize) % count;
+ self.chr_rom[bank * CHR_BANK_2K + (addr as usize & 0x07FF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/m250_nitra250.rs b/crates/rustynes-mappers/src/m250_nitra250.rs
index 52be1e277..58ca00e60 100644
--- a/crates/rustynes-mappers/src/m250_nitra250.rs
+++ b/crates/rustynes-mappers/src/m250_nitra250.rs
@@ -220,6 +220,15 @@ impl Mapper for Nitra250 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.read_chr(addr),
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
diff --git a/crates/rustynes-mappers/src/m252_waixing_vrc4.rs b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs
new file mode 100644
index 000000000..f29886697
--- /dev/null
+++ b/crates/rustynes-mappers/src/m252_waixing_vrc4.rs
@@ -0,0 +1,332 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Mapper 252: Waixing's VRC4-clone board for *San Guo Zhi* (v3.2.0).
+//!
+//! Written from the vendored `NESdev` page `INES_Mapper_252`; no
+//! reference-emulator source was read. The board carries Waixing's Chinese
+//! localization of Namcot's *Sangokushi: Chuugen no Hasha*. Per the page it
+//! "uses a clone of the Konami VRC4, with the chip's A0 connected to CPU A2
+//! and the chip's A1 connected to CPU A3 (`VRC4e`, Mapper 23 submapper 2). It
+//! has 8 KiB of PRG-RAM at CPU `$6000-$7FFF`, and 1 KiB CHR pages 6 and 7 are
+//! CHR-RAM."
+//!
+//! So the board is the project's own [`Vrc4`] (`m021_vrc4.rs`) wired as
+//! mapper 23 submapper 2, which already supplies the PRG and CHR banking, the
+//! mirroring register, the IRQ counter and the 8 KiB PRG-RAM, plus 2 KiB of
+//! CHR-RAM: any 1 KiB PPU slot whose CHR bank register holds 6 reads and
+//! writes the first 1 KiB of that RAM, and one holding 7 the second, instead
+//! of CHR-ROM pages 6 and 7. That is the same "these bank numbers are RAM"
+//! arrangement the page compares with the Waixing MMC3 boards (74, 192, 194,
+//! 195).
+//!
+//! Mapper 253 is "the same, except that it's 1 KiB CHR pages 4 and 5 that are
+//! CHR-RAM". The project's mapper 253 (`waixing.rs`) is an older, separately
+//! derived model and is deliberately not reused here.
+//!
+//! The page states only the bank numbers, not how many bits of the VRC4's CHR
+//! register take part in the match. This model compares the whole register
+//! value (`== 6` / `== 7`), the plain reading of "pages 6 and 7"; *San Guo
+//! Zhi* boots and plays under it.
+
+use crate::cartridge::Mirroring;
+use crate::m021_vrc4::Vrc4;
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const CHR_BANK_1K: usize = 0x0400;
+/// The two CHR bank numbers that select CHR-RAM instead of CHR-ROM.
+const RAM_PAGES: [u8; 2] = [6, 7];
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// Mapper 252 (Waixing VRC4 clone with CHR-RAM pages 6 and 7).
+pub struct Waixing252 {
+ inner: Vrc4,
+ /// 2 KiB of CHR-RAM: pages 6 and 7.
+ chr_ram: Box<[u8]>,
+}
+
+impl Waixing252 {
+ /// Construct the board around a `VRC4e` (mapper 23 submapper 2) core.
+ ///
+ /// # Errors
+ ///
+ /// As [`Vrc4::new`]: PRG a non-zero multiple of 8 KiB, CHR-ROM a multiple
+ /// of 1 KiB. Also when there is no CHR-ROM: the board is CHR-ROM with two
+ /// pages of CHR-RAM ("1 KiB CHR pages 6 and 7 are CHR-RAM",
+ /// `INES_Mapper_252`), and without ROM `Vrc4::new` would build 8 KiB of
+ /// CHR-RAM that this wrapper's page-6/7 routing never reaches.
+ pub fn new(
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ ) -> Result {
+ // CodeRabbit on #608 found writes to pages 0-5 dropped on a CHR-less
+ // image. Such an image is not this board, so it is refused here rather
+ // than routed (the VRC4's own CHR-RAM writes unbanked but reads
+ // banked, T-VRC4-CHRRAM-BANKING, so routing would not have worked).
+ if chr_rom.is_empty() {
+ return Err(MapperError::Invalid(
+ "mapper 252 is a CHR-ROM board (pages 6 and 7 are CHR-RAM); this image has no CHR-ROM"
+ .into(),
+ ));
+ }
+ Ok(Self {
+ inner: Vrc4::new(prg_rom, chr_rom, 23, 2, mirroring)?,
+ chr_ram: vec![0u8; 2 * CHR_BANK_1K].into_boxed_slice(),
+ })
+ }
+
+ /// The CHR-RAM offset for pattern address `addr`, when its slot's bank is
+ /// page 6 or 7.
+ fn ram_offset(&self, addr: u16) -> Option {
+ let slot = usize::from((addr >> 10) & 0x07);
+ let bank = self.inner.chr_reg(slot);
+ RAM_PAGES
+ .iter()
+ .position(|&p| p == bank)
+ .map(|page| page * CHR_BANK_1K + usize::from(addr & 0x03FF))
+ }
+}
+
+impl Mapper for Waixing252 {
+ fn caps(&self) -> MapperCaps {
+ self.inner.caps()
+ }
+
+ fn sram(&self) -> &[u8] {
+ self.inner.sram()
+ }
+
+ fn sram_mut(&mut self) -> &mut [u8] {
+ self.inner.sram_mut()
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ self.inner.cpu_read_unmapped(addr)
+ }
+
+ fn may_unmap_prg_rom(&self) -> bool {
+ self.inner.may_unmap_prg_rom()
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ self.inner.cpu_read(addr)
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ self.inner.cpu_write(addr, value);
+ }
+
+ fn chr_phys(&self, addr: u16) -> Option {
+ // CHR-RAM pages are content-hashed by the HD-pack path; ROM pages
+ // fall back to it too, as the wrapped VRC4 does.
+ let _ = addr;
+ None
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr < 0x2000
+ && let Some(off) = self.ram_offset(addr)
+ {
+ return self.chr_ram[off];
+ }
+ self.inner.ppu_read(addr)
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr < 0x2000
+ && let Some(off) = self.ram_offset(addr)
+ {
+ return self.chr_ram[off];
+ }
+ self.inner.ppu_peek(addr)
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ if addr < 0x2000 {
+ if let Some(off) = self.ram_offset(addr) {
+ self.chr_ram[off] = value;
+ }
+ // Pages other than 6 and 7 are CHR-ROM: a write there goes nowhere.
+ // `new` refuses an image without CHR-ROM, so there is no CHR-RAM
+ // in the VRC4 for such a write to have reached.
+ return;
+ }
+ self.inner.ppu_write(addr, value);
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ self.inner.notify_cpu_cycle();
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.inner.irq_pending()
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.inner.irq_acknowledge();
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.inner.current_mirroring()
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = self.inner.debug_info();
+ info.mapper_id = 252;
+ info.name = "Waixing VRC4 clone, CHR-RAM pages 6/7 (252)".into();
+ info.extra.push((
+ "CHR-RAM slots".into(),
+ format!(
+ "{:?}",
+ (0..8)
+ .filter(|&s| RAM_PAGES.contains(&self.inner.chr_reg(s)))
+ .collect::>()
+ ),
+ ));
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let inner = self.inner.save_state();
+ let mut out = Vec::with_capacity(1 + self.chr_ram.len() + inner.len());
+ out.push(SAVE_STATE_VERSION);
+ out.extend_from_slice(&self.chr_ram);
+ out.extend_from_slice(&inner);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ let head = 1 + self.chr_ram.len();
+ if data.len() < head {
+ return Err(MapperError::WrongLength {
+ expected: head,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ // The inner blob validates itself before anything is written to the
+ // core; the CHR-RAM is copied only once it has.
+ self.inner.load_state(&data[head..])?;
+ self.chr_ram.copy_from_slice(&data[1..head]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::parse;
+
+ /// A mapper 252 image through `crate::parse`: 256 KiB PRG, 128 KiB CHR
+ /// whose every 1 KiB page opens with its index.
+ fn board() -> Box {
+ let mut v = vec![0u8; 16];
+ v[0..4].copy_from_slice(b"NES\x1A");
+ v[4] = 16;
+ v[5] = 16;
+ v[6] = 0xC1; // 252 = 0xFC: low nibble 0xC, vertical
+ v[7] = 0xF0;
+ for b in 0..32u8 {
+ let mut bank = vec![0u8; 0x2000];
+ bank[0] = b;
+ v.extend_from_slice(&bank);
+ }
+ for p in 0..128u8 {
+ let mut page = vec![0u8; CHR_BANK_1K];
+ page[0] = p;
+ v.extend_from_slice(&page);
+ }
+ parse(&v)
+ .unwrap_or_else(|e| panic!("mapper 252 must parse: {e:?}"))
+ .1
+ }
+
+ /// Write a CHR bank register through the `VRC4e` decode: chip A0 = CPU A2,
+ /// chip A1 = CPU A3. `base` is `$B000`..`$E000`, `second` picks the odd
+ /// slot of the pair.
+ fn chr(m: &mut dyn Mapper, base: u16, second: bool, value: u8) {
+ let a1 = if second { 0x08 } else { 0 };
+ m.cpu_write(base | a1, value & 0x0F); // low nibble (chip A0 = 0)
+ m.cpu_write(base | a1 | 0x04, value >> 4); // high nibble (chip A0 = 1)
+ }
+
+ #[test]
+ fn m252_vrc4e_decode_selects_chr_rom_pages() {
+ let mut m = board();
+ chr(m.as_mut(), 0xB000, false, 0x25);
+ chr(m.as_mut(), 0xB000, true, 0x31);
+ chr(m.as_mut(), 0xE000, true, 0x7F);
+ assert_eq!(m.ppu_read(0x0000), 0x25);
+ assert_eq!(m.ppu_read(0x0400), 0x31, "A3 selects the second slot");
+ assert_eq!(m.ppu_read(0x1C00), 0x7F);
+ }
+
+ #[test]
+ fn m252_pages_6_and_7_are_chr_ram_and_others_are_rom() {
+ let mut m = board();
+ chr(m.as_mut(), 0xB000, false, 6);
+ chr(m.as_mut(), 0xB000, true, 7);
+ chr(m.as_mut(), 0xC000, false, 5);
+ chr(m.as_mut(), 0xC000, true, 4);
+ m.ppu_write(0x0010, 0xAA); // slot 0 -> page 6 (RAM)
+ m.ppu_write(0x0410, 0xBB); // slot 1 -> page 7 (RAM)
+ m.ppu_write(0x0810, 0xCC); // slot 2 -> page 5 (ROM: ignored)
+ assert_eq!(m.ppu_read(0x0010), 0xAA);
+ assert_eq!(m.ppu_read(0x0410), 0xBB);
+ assert_eq!(m.ppu_read(0x0810), 0x00, "ROM page 5 is not writable");
+ assert_eq!(m.ppu_read(0x0800), 5, "ROM page 5");
+ assert_eq!(m.ppu_read(0x0C00), 4, "ROM page 4 (253's RAM, not 252's)");
+ // The same RAM page seen through another slot.
+ chr(m.as_mut(), 0xE000, false, 7);
+ assert_eq!(m.ppu_read(0x1810), 0xBB, "page 7 is one 1 KiB of RAM");
+ // A bank number that only shares low bits with 6 is ROM.
+ chr(m.as_mut(), 0xB000, false, 0x16);
+ assert_eq!(m.ppu_read(0x0000), 0x16);
+ }
+
+ /// An image with no CHR-ROM is not this board: refused, rather than built
+ /// with pattern writes that go nowhere.
+ #[test]
+ fn m252_without_chr_rom_is_refused() {
+ let prg = vec![0u8; 0x8000].into_boxed_slice();
+ let r = Waixing252::new(prg, Box::new([]), Mirroring::Vertical);
+ assert!(matches!(r, Err(MapperError::Invalid(_))));
+ }
+
+ #[test]
+ fn m252_prg_ram_and_irq_come_from_the_vrc4() {
+ let mut m = board();
+ m.cpu_write(0x6123, 0x5A);
+ assert_eq!(m.cpu_read(0x6123), 0x5A, "8 KiB PRG-RAM");
+ assert_eq!(m.sram().len(), 0x2000);
+ // IRQ latch $FF, enable, M = 1 (cycle mode, `VRC_IRQ`): fires on the
+ // next CPU cycle. Control is at chip A1 = CPU A3.
+ m.cpu_write(0xF000, 0x0F);
+ m.cpu_write(0xF004, 0x0F);
+ m.cpu_write(0xF008, 0x06);
+ assert!(!m.irq_pending());
+ m.notify_cpu_cycle();
+ assert!(m.irq_pending());
+ assert_eq!(m.caps(), MapperCaps::CYCLE_IRQ);
+ }
+
+ #[test]
+ fn m252_state_round_trips_with_chr_ram() {
+ let mut a = board();
+ chr(a.as_mut(), 0xD000, false, 6);
+ a.ppu_write(0x1000, 0x42);
+ a.cpu_write(0x6000, 0x99);
+ let blob = a.save_state();
+ let mut b = board();
+ b.load_state(&blob).unwrap();
+ assert_eq!(b.save_state(), blob);
+ assert_eq!(b.ppu_read(0x1000), 0x42);
+ assert_eq!(b.cpu_read(0x6000), 0x99);
+ assert!(b.load_state(&blob[..10]).is_err());
+ }
+}
diff --git a/crates/rustynes-mappers/src/m268_bmc_coolboy.rs b/crates/rustynes-mappers/src/m268_bmc_coolboy.rs
index 3fc9b378e..9d914671b 100644
--- a/crates/rustynes-mappers/src/m268_bmc_coolboy.rs
+++ b/crates/rustynes-mappers/src/m268_bmc_coolboy.rs
@@ -335,6 +335,22 @@ impl Mapper for Coolboy {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ let slot = (addr as usize) / CHR_BANK_1K;
+ let b = self.resolve_chr(slot);
+ self.chr[b * CHR_BANK_1K + (addr as usize & 0x3FF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/m513_sachen_9602.rs b/crates/rustynes-mappers/src/m513_sachen_9602.rs
index bc70cf7f1..806b57c68 100644
--- a/crates/rustynes-mappers/src/m513_sachen_9602.rs
+++ b/crates/rustynes-mappers/src/m513_sachen_9602.rs
@@ -237,6 +237,15 @@ impl Mapper for Sachen9602 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize & (CHR_BANK_8K - 1)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/mapper.rs b/crates/rustynes-mappers/src/mapper.rs
index e71c6c487..2611cd5df 100644
--- a/crates/rustynes-mappers/src/mapper.rs
+++ b/crates/rustynes-mappers/src/mapper.rs
@@ -57,7 +57,7 @@ pub struct BgSplitState {
/// Mappers override [`Mapper::debug_info`] to surface their banking
/// registers and any IRQ-counter state. Fields are pre-formatted strings
/// so the UI doesn't have to know the protocol of each mapper.
-#[derive(Debug, Default, Clone)]
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MapperDebugInfo {
/// Mapper id (e.g. `4` for MMC3).
pub mapper_id: u16,
@@ -213,6 +213,21 @@ pub trait Mapper: Send {
/// Read a byte from the CPU address space `$4020-$FFFF`.
fn cpu_read(&mut self, addr: u16) -> u8;
+ /// Read a CPU byte (`$4020-$FFFF`) for the debugger, the disassembler
+ /// and scripts, which must never change the game.
+ ///
+ /// `SystemBus::debug_peek_cpu` calls this rather than
+ /// [`Self::cpu_read`]. The default IS `cpu_read`, which is right for every
+ /// board whose CPU read changes nothing; a board whose read consumes
+ /// state overrides it to return the same byte and keep the state. Added
+ /// in v3.2.0 for the FFE boards (6, 8, 17), whose trainer boot is a
+ /// one-shot that the CPU's reset-vector and stub reads retire: a debugger
+ /// peek at `$FFFD` or a disassembly through the stub used to retire it
+ /// first, so the trainer never ran (agy on #604).
+ fn cpu_peek(&mut self, addr: u16) -> u8 {
+ self.cpu_read(addr)
+ }
+
/// Write a byte to the CPU address space `$4020-$FFFF`.
fn cpu_write(&mut self, addr: u16, value: u8);
@@ -252,6 +267,25 @@ pub trait Mapper: Send {
}
}
+ /// Whether [`Self::cpu_read_unmapped`] can ever return `true` for an
+ /// address in `$8000-$FFFF`, on this board, in any state.
+ ///
+ /// Almost every cartridge drives the bus across the whole PRG-ROM window,
+ /// so the bus skips the per-read [`Self::cpu_read_unmapped`] call there
+ /// for every board that leaves this `false` -- PRG-ROM fetches are the
+ /// hottest reads in the machine. A board that can leave part of
+ /// `$8000-$FFFF` undriven (an absent chip, an empty socket) returns
+ /// `true`, and the bus then asks on every read as before. Today that is
+ /// *Action 52* (228) and the Golden Game multicart (235). A board that
+ /// wraps another must forward this along with `cpu_read_unmapped`.
+ ///
+ /// Read once when the bus takes the mapper, so it must not depend on
+ /// runtime state. Debug builds check the contract on every PRG read. The
+ /// default is `false`. Added in v3.2.0.
+ fn may_unmap_prg_rom(&self) -> bool {
+ false
+ }
+
/// A CPU read the mapper declined ([`Self::cpu_read_unmapped`]) has just
/// completed, and `value` is what floated on the bus.
///
@@ -307,6 +341,22 @@ pub trait Mapper: Send {
/// + nametable mirror window). Used as the BG-side / generic fetch path.
fn ppu_read(&mut self, addr: u16) -> u8;
+ /// Read a PPU byte (`$0000-$3FFF`) without side effects, for the
+ /// debugger and other inspection.
+ ///
+ /// It must return exactly the byte [`Self::ppu_read`] would return at
+ /// `addr` in the current state, and change nothing: a CHR latch the read
+ /// would flip is resolved as flipped but not stored, an IRQ counter a
+ /// read would clock is not clocked. `SystemBus::debug_peek_ppu` relies on
+ /// it for every board (until v3.2.0 it called `ppu_read` inside a save /
+ /// restore), and `ppu_peek_matches_a_side_effect_free_ppu_read` checks it
+ /// for every mapper.
+ ///
+ /// Required, with no default: a default `0` (as first written in v3.2.0)
+ /// would make a board that forgot it show a blank pattern table in the
+ /// debugger with no other symptom.
+ fn ppu_peek(&self, addr: u16) -> u8;
+
/// Read a byte from the PPU pattern-table window (`$0000-$1FFF`) on
/// behalf of a *sprite* tile fetch. MMC5 in 8x16 sprite mode uses a
/// separate set of CHR bank registers (`$5120-$5127`) for sprite
@@ -459,8 +509,10 @@ pub trait Mapper: Send {
/// on a console. The boards that clear something are documented per page:
/// mapper 37's outer latch (`INES_Mapper_037.md`), mapper 45's outer
/// registers, NES-EVENT's PRG lock (mapper 105), the 76-in-1 BMC's two
- /// registers (mapper 226, since v2.9.8), and Action 52's register
- /// (mapper 228). Added in v2.9.6; a power cycle rebuilds the mapper
+ /// registers (mapper 226, since v2.9.8), Action 52's register
+ /// (mapper 228), the 22-in-1's chip toggle (mapper 230, v3.2.0, where
+ /// RESET is the mode switch), and the Golden Game multicart's register
+ /// (mapper 235, v3.2.0). Added in v2.9.6; a power cycle rebuilds the mapper
/// instead and never calls this.
fn reset(&mut self) {}
@@ -703,6 +755,28 @@ pub trait Mapper: Send {
self.save_data_mut().fill(0);
}
+ /// Whether [`Self::save_data`] may have changed since the last
+ /// [`Self::clear_save_data_dirty`] (or since the board was built).
+ ///
+ /// `None` -- the default -- means "not tracked": the battery writer then
+ /// compares the bytes, as it always has. A board that returns `Some` lets
+ /// the writer SKIP that comparison while it reports `Some(false)`
+ /// (`BatteryPolicy::differs`), which matters for the two self-flashable
+ /// boards whose save data is the whole multi-hundred-KiB PRG image. So
+ /// `Some(false)` is a promise: every path that can change the save data
+ /// -- a program write, [`Self::save_data_mut`], [`Self::clear_save_data`]
+ /// and [`Self::load_state`] -- must set the flag, or a change is never
+ /// saved. Added in v3.2.0.
+ fn is_save_data_dirty(&self) -> Option {
+ None
+ }
+
+ /// Mark the save data clean (see [`Self::is_save_data_dirty`]). Nothing in
+ /// the shipped frontends calls it yet, so a tracked board stays dirty after
+ /// its first change and the comparison resumes; it is the hook a writer
+ /// would call after a save it knows to be current. Default: nothing.
+ fn clear_save_data_dirty(&mut self) {}
+
/// Start recording the diagnostic FDS read-stream trace (off by default;
/// observation-only). No-op for non-FDS mappers. See [`crate::FdsTraceRec`].
fn enable_fds_trace(&mut self) {}
@@ -756,6 +830,27 @@ pub trait Mapper: Send {
false
}
+ /// Whether this cartridge has a barcode reader the player can scan
+ /// with. Only the Datach Joint ROM System (mapper 157) does. Default
+ /// `false`. Added in v3.2.0.
+ fn has_barcode_reader(&self) -> bool {
+ false
+ }
+
+ /// Present `code` to the barcode reader, as if a card were swiped past
+ /// it, starting on the next CPU cycle and replacing any scan in flight.
+ /// Returns `false` (and does nothing) on a board without a reader. The
+ /// stream is timed in CPU cycles and saved with the mapper state; see
+ /// `datach_barcode.rs`. Added in v3.2.0.
+ fn scan_barcode(&mut self, _code: &crate::Barcode) -> bool {
+ false
+ }
+
+ /// Whether a barcode scan is still being presented. Default `false`.
+ fn barcode_scan_in_progress(&self) -> bool {
+ false
+ }
+
/// Encode the mapper's mutable state into a tagged save-state blob.
fn save_state(&self) -> Vec;
@@ -878,11 +973,119 @@ mod caps_tests {
assert!(checked > 150, "only {checked} boards were checked");
assert_eq!(
impure,
- vec![9, 10, 35, 90, 96, 163, 209, 211],
+ // v3.2.0: 165 (MMC3 + MMC2-style CHR latches).
+ vec![9, 10, 35, 90, 96, 163, 165, 209, 211],
"the impure set moved"
);
}
+ /// v3.2.0: the bus skips `cpu_read_unmapped` in `$8000-$FFFF` for every
+ /// board that does not declare `may_unmap_prg_rom`, so a board that can
+ /// float that window and does not declare it would silently read ROM.
+ /// Every parsable mapper number is driven through a spread of register
+ /// writes, and after each one the window is asked; an undeclared `true`
+ /// fails. The declared set is pinned too, so adding a board to it is a
+ /// visible change. Each number is tried with CHR-ROM and again with
+ /// CHR-RAM, since some boards (235 among them) refuse one or the other.
+ #[test]
+ fn only_declared_boards_unmap_prg_rom() {
+ fn chr_ram_rom(id: u8) -> Vec {
+ let mut rom = synth_rom(id);
+ rom[5] = 0; // no CHR-ROM: CHR-RAM
+ rom.truncate(16 + 32 * 1024);
+ rom
+ }
+ let mut declared = Vec::new();
+ let mut checked = 0usize;
+ for id in 0u8..=255 {
+ for rom in [synth_rom(id), chr_ram_rom(id)] {
+ let Ok((_cart, mut mapper)) = parse(&rom) else {
+ continue;
+ };
+ if mapper.may_unmap_prg_rom() {
+ if !declared.contains(&id) {
+ declared.push(id);
+ }
+ continue;
+ }
+ for page in (0x8000u16..=0xFF00).step_by(0x100) {
+ for value in [0x00u8, 0x5A, 0xFF] {
+ mapper.cpu_write(page, value);
+ for addr in [0x8000u16, 0xA000, 0xC000, 0xE000, 0xFFFC] {
+ assert!(
+ !mapper.cpu_read_unmapped(addr),
+ "mapper {id} floats ${addr:04X} after writing ${value:02X} \
+ to ${page:04X} but does not declare may_unmap_prg_rom"
+ );
+ }
+ }
+ }
+ checked += 1;
+ }
+ }
+ assert!(checked > 150, "only {checked} boards were checked");
+ assert_eq!(declared, vec![228, 235], "the declared set moved");
+ }
+
+ /// v3.2.0: `ppu_peek` returns exactly the byte `ppu_read` would, without
+ /// its side effects. The debugger's PPU view (`SystemBus::debug_peek_ppu`)
+ /// reads CHR through `ppu_peek` on every board; before v3.2.0 it called
+ /// `ppu_read` and, on a board with impure CHR reads, restored the state
+ /// afterwards. That old behaviour is the oracle here: for every parsable
+ /// mapper (CHR-ROM and CHR-RAM), after a spread of register writes, each
+ /// sampled CHR address is read through `ppu_read` inside a save / restore
+ /// and compared with `ppu_peek`.
+ #[test]
+ fn ppu_peek_matches_a_side_effect_free_ppu_read() {
+ fn chr_ram_rom(id: u8) -> Vec {
+ let mut rom = synth_rom(id);
+ rom[5] = 0;
+ rom.truncate(16 + 32 * 1024);
+ rom
+ }
+ fn rom_with_patterned_chr(id: u8) -> Vec {
+ let mut rom = synth_rom(id);
+ for (i, b) in rom[16 + 32 * 1024..].iter_mut().enumerate() {
+ let [lo, hi, ..] = i.to_le_bytes();
+ *b = lo.wrapping_mul(31) ^ hi;
+ }
+ rom
+ }
+ let mut checked = 0usize;
+ for id in 0u8..=255 {
+ for rom in [rom_with_patterned_chr(id), chr_ram_rom(id)] {
+ let Ok((_cart, mut mapper)) = parse(&rom) else {
+ continue;
+ };
+ for (reg, value) in [
+ (0x8000u16, 0x00u8),
+ (0x8001, 0x03),
+ (0xA000, 0x05),
+ (0xC000, 0x1F),
+ ] {
+ mapper.cpu_write(reg, value);
+ // A stride across the window, plus the MMC2 / MMC4-style
+ // latch triggers, where a read changes the bank it reads.
+ let triggers = [0x0FD8u16, 0x0FE8, 0x1FD8, 0x1FE8];
+ for addr in (0u16..0x2000).step_by(0x0FD).chain(triggers) {
+ let saved = mapper.save_state();
+ let read = mapper.ppu_read(addr);
+ mapper
+ .load_state(&saved)
+ .expect("a board reloads its own state");
+ assert_eq!(
+ mapper.ppu_peek(addr),
+ read,
+ "mapper {id}: ppu_peek(${addr:04X}) differs from ppu_read"
+ );
+ }
+ }
+ checked += 1;
+ }
+ }
+ assert!(checked > 150, "only {checked} boards were checked");
+ }
+
/// v2.8.0 Phase 4 — the capability-flag contract for the key families.
/// A flag may be `false` ONLY when the mapper does not override the
/// corresponding default no-op; these spot checks pin the mechanical
diff --git a/crates/rustynes-mappers/src/mmc3_boards.rs b/crates/rustynes-mappers/src/mmc3_boards.rs
index 9d383004e..ee468571f 100644
--- a/crates/rustynes-mappers/src/mmc3_boards.rs
+++ b/crates/rustynes-mappers/src/mmc3_boards.rs
@@ -219,7 +219,7 @@ fn t9552_scramble(
/// power-of-two `count` it is exactly `bank % count`. No allocation, a few
/// iterations at most (one per set bit of `count`): this runs on every PRG and
/// CHR-ROM access.
-fn mirror_bank(bank: usize, count: usize) -> usize {
+pub(crate) fn mirror_bank(bank: usize, count: usize) -> usize {
if count == 0 {
return 0;
}
@@ -761,6 +761,15 @@ impl Mapper for Mmc3Board {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr < 0x2000 {
+ self.read_chr(addr)
+ } else {
+ self.core.ppu_peek(addr)
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if addr >= 0x2000 {
diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext.rs b/crates/rustynes-mappers/src/mmc3_boards_ext.rs
new file mode 100644
index 000000000..b05114da2
--- /dev/null
+++ b/crates/rustynes-mappers/src/mmc3_boards_ext.rs
@@ -0,0 +1,816 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! MMC3-clone boards added in v3.2.0, written from their NESdev pages:
+//! mappers 114 and 182 (the SuperGame / Hosenkan register scramblers) and
+//! 215 (Sugar Softec's selectable scrambler, UNL-8237 / UNL-8237A), 197
+//! (an MMC3 rewired for 2 / 4 KiB CHR banks) and 165 (an MMC3 with MMC2-style
+//! CHR latches).
+//!
+//! These are, like the boards in `mmc3_boards.rs`, a stock MMC3 with a
+//! board-specific layer in front of it or behind it. The difference that
+//! earns them a module of their own is *where* the layer sits: these boards
+//! rewrite the CPU's register writes before the MMC3 sees them (an address
+//! and index scramble), or rewire the PPU address lines the MMC3 sees, rather
+//! than only post-processing the MMC3's bank outputs. The MMC3 itself is still
+//! the project's own [`Mmc3`] used as a register file and IRQ counter
+//! ([`Mmc3::register_core`]), so the A12 filter and the Sharp / NEC IRQ
+//! revisions of `m004_mmc3.rs` are shared, and the mapper 4 path is untouched.
+//!
+//! **Provenance.** Each board is implemented from the vendored NESdev page
+//! named on its [`ExtBoard`] variant (`nesdev_wiki/output/INES_Mapper_NNN.md`)
+//! and from `MMC3.md`. No reference-emulator source was read. This file is
+//! kept apart from `mmc3_clones.rs` for the same reason `mmc3_boards.rs` is:
+//! that file carries a Mesen2 derivation record, and independently written
+//! boards must not blur which regions it covers.
+//!
+//! **Bank arithmetic.** Every bank is reduced onto the image by
+//! [`mirror_bank`] (the NESdev doubling rule for non-power-of-two sizes), so no
+//! register value can index out of bounds. The chip stack is `#![no_std]` and
+//! cannot afford a panic on a register write.
+//!
+//! ## Mapper 114 / 182 (`INES_Mapper_114.md`)
+//!
+//! An MMC3 clone whose register *addresses* and `$8000` *indices* are
+//! permuted, plus two board registers that are not on the MMC3's WRAM
+//! interface (they answer whether or not WRAM is enabled):
+//!
+//! - `$6000` (mask `$E001`): `M.S. BBBb`. With `M` set, a 16 KiB NROM-style
+//! bank `BBBb` replaces the MMC3's PRG banks at both halves; `S` set makes it
+//! NROM-256 by substituting CPU A14 for `b`.
+//! - `$6001` (mask `$E001`): bit 0 is CHR A18, a 256 KiB outer CHR bank.
+//!
+//! Submapper 0 (Aladdin, The Lion King, Hosenkan's titles) and submapper 1
+//! (Boogerman) use different permutations; both tables are on the page and are
+//! transcribed into [`M114_SUB0`] / [`M114_SUB1`]. The page states the IRQ
+//! behaves like the MMC3A's (a latch of 0 disables it, which Aladdin needs),
+//! so the core runs [`Mmc3Revision::Nec`], the project's name for that
+//! behaviour.
+//!
+//! **Mapper 182 is mapper 114.** `INES_Mapper_182.md` is a one-line redirect
+//! to the 114 page, which says 182 "is a duplicate of Mapper 114" and that
+//! Hosenkan's 182 games write zero to `$6000` / `$6001` once and never again.
+//! So 182 is dispatched to mapper 114 submapper 0 with no separate model. The
+//! zero writes are harmless under the full 114 model (NROM override off, CHR
+//! A18 = 0), so there is no reason to model the "no `$600x` registers"
+//! simplification some implementations use.
+//!
+//! ## Mapper 215 (`INES_Mapper_215.md`)
+//!
+//! Sugar Softec's later boards: the 114 idea with the scramble made
+//! selectable and the board registers moved to `$5000-$5FFF` (mask `$F007`):
+//!
+//! - `$5000` `MCS. BBBb` (power-up `$00`): `M` and `S`/`BBBb` are 114's NROM
+//! override; `C` selects 128 KiB outer banks, replacing PRG A17 and CHR A17
+//! with `$5001` bits 4 and 5.
+//! - `$5001` (power-up `$xF`, and reset to it on a console reset): the outer
+//! bank. Submapper 0 (UNL-8237): PRG A18-A19 = bits 0-1, CHR A18-A19 =
+//! bits 2-3. Submapper 1 (UNL-8237A): PRG A18/A19/A20 = bits 0/1/3, CHR
+//! A18/A19/A20 = bits 1/2/3.
+//! - `$5007` (power-up `$00`): the scramble pattern, 0-7. Patterns 0, 2, 5, 6
+//! and 7 are the identity; 1, 3 and 4 permute the MMC3 register addresses
+//! and the `$8000` index per the page's two tables ([`M215_SCRAMBLE`]).
+//!
+//! The page gives no IRQ revision, saying only that the unscrambled registers
+//! "function the same as the MMC3's", so the default (Sharp) behaviour is used.
+//! Where the page leaves the NROM override and the 128 KiB mode's A17
+//! substitution unordered, `C` is applied last, to whichever bank (MMC3 or
+//! NROM) is in force: it is described as a substitution of the PRG A17 line,
+//! which both sources drive.
+//!
+//! ## Mapper 197 (`INES_Mapper_197.md`)
+//!
+//! An MMC3 clone wired for 512 KiB of CHR-ROM by doubling the CHR bank
+//! granularity: PPU A10 goes straight to CHR-ROM A10, the MMC3's CHR outputs
+//! A10-A17 drive CHR-ROM A11-A18, and the MMC3's own PPU-address inputs are
+//! rewired so that it sees PPU A11 on its PA10. The page gives the wiring per
+//! submapper and the resulting bank table; this model implements the
+//! *wiring*, not the table:
+//!
+//! | Sub | MMC3 PA10 | MMC3 PA11 | MMC3 PA12 |
+//! |-----|-----------|-----------|-----------|
+//! | 0, 3 | PPU A11 | GND | PPU A12 |
+//! | 1 | PPU A11 | Vcc | PPU A12 |
+//! | 2 | PPU A11 | PPU A11 | PPU A12 |
+//!
+//! For a pattern-table address the board forms the address the MMC3 sees,
+//! asks the MMC3 which 1 KiB bank it would select there
+//! ([`Mmc3::chr_bank_1k`]), and builds the ROM address as that bank on
+//! A11-A18 with PPU A10 below it. The page's tables (submapper 0: R0 as
+//! 4 KiB at `$0000`, R2 / R3 as 2 KiB at `$1000` / `$1800`, R1, R4 and R5
+//! unused) fall out of this, and so does `$8000` bit 7 (the CHR A12
+//! inversion), which the page does not mention: the MMC3 still has it, and
+//! with PA12 wired to PPU A12 it still swaps which registers each pattern
+//! table sees. PA12 is unchanged, so the A12 IRQ counter is the MMC3's.
+//!
+//! Submapper 3 adds an outer register at `$6000-$7FFF` (mask `$E000`) on the
+//! MMC3's WRAM interface (taken only while `$A001` allows a WRAM write):
+//! `.... S..P`, with `S` set making PRG A17 = `P` (128 KiB) instead of the
+//! MMC3's A17. iNES 1.0 images take submapper 0. The page names a PRG CRC32
+//! for its one submapper 1 and one submapper 2 game; detecting them is the
+//! per-game database's job, not the board's.
+//!
+//! ## Mapper 165 (`INES_Mapper_165.md`)
+//!
+//! The page is Disch's notes only: "a strange MMC2+MMC3 hybrid". PRG, PRG-RAM
+//! enable, mirroring "and even IRQs" are the MMC3's; the CHR registers are
+//! used MMC2-style, two 4 KiB windows each chosen by a tile latch:
+//!
+//! - `$0000-$0FFF`: R0 while latch 0 is `$FD`, R1 while it is `$FE`;
+//! - `$1000-$1FFF`: R2 while latch 1 is `$FD`, R4 while it is `$FE`.
+//!
+//! "CHR page 0 is CHR-RAM, other pages are CHR-ROM", with 4 KiB of CHR-RAM
+//! beside the ROM, and "SRAM enabling" as on the MMC3, so the board has 8 KiB
+//! of work RAM even under an iNES 1.0 header. Three readings are this
+//! model's, stated where the notes are silent or loose:
+//!
+//! - **A page is the register value over 4.** The MMC3's registers count
+//! 1 KiB units and a window is 4 KiB, so page = R >> 2 (MMC3 CHR A12 and
+//! up), and page `n` reads CHR-ROM at `n * 4 KiB` (page 0's ROM is
+//! shadowed by the RAM).
+//! - **The latches are the MMC2's.** "The same latches that exist on MMC2
+//! exist on this mapper as well": latch 0 switches on `$0FD8` / `$0FE8`
+//! exactly, latch 1 on `$1FD8-$1FDF` / `$1FE8-$1FEF`, after the read that
+//! hit them (`MMC2.md`). The notes also say the game reads "`$xFD0` or
+//! `$xFE0` via `$2007`" to toggle a latch by hand; that is not taken as a
+//! trigger address. Measured on the Fire Emblem translation: triggering on
+//! all of `$xFD0-$xFDF` draws a stray vertical bar before every line of
+//! intro text (the high plane of the switching tile comes from the new
+//! bank), while the MMC2's triggers draw the same screens (title, intro
+//! text, unit cards) clean. Which of them the game draws through a manual
+//! `$2007` toggle was not determined.
+//! - **Work RAM is required, not optional.** Without it the *Fire Emblem
+//! Gaiden* translation's title screen is garbage tiles: it builds the title
+//! in RAM it reads back.
+//!
+//! `$8000` bit 7 (CHR inversion) is not part of the notes' diagram and has
+//! no effect here. Because a pattern read can switch banks, the board reports
+//! impure CHR reads ([`Mapper::chr_reads_are_pure`]).
+
+#![allow(
+ clippy::cast_possible_truncation,
+ clippy::cast_lossless,
+ clippy::missing_const_for_fn,
+ clippy::doc_markdown
+)]
+
+use crate::cartridge::Mirroring;
+use crate::m004_mmc3::{Mmc3, Mmc3Revision};
+use crate::mapper::{Mapper, MapperCaps, MapperDebugInfo, MapperError};
+use crate::mmc3_boards::mirror_bank;
+use alloc::string::ToString;
+use alloc::{boxed::Box, format, vec, vec::Vec};
+
+const PRG_BANK_8K: usize = 0x2000;
+const CHR_BANK_1K: usize = 0x0400;
+
+/// Save-state layout version for [`Mmc3BoardExt`].
+const SAVE_STATE_VERSION: u8 = 1;
+
+/// Fixed head of the save state: version, id, variant tag, four registers,
+/// the latch byte (165: bit 0 / bit 1 = latch 0 / 1 at `$FE`), and the
+/// embedded MMC3 state's length.
+const HEAD: usize = 3 + 4 + 1 + 4;
+
+/// Which board an [`Mmc3BoardExt`] models.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ExtBoard {
+ /// Mapper 114 (and 182), `INES_Mapper_114.md`. `sub1` selects the
+ /// submapper 1 (Boogerman) scrambling pattern.
+ M114 {
+ /// NES 2.0 submapper 1.
+ sub1: bool,
+ },
+ /// Mapper 215, `INES_Mapper_215.md`. `sub1` selects the UNL-8237A outer
+ /// bank layout (PRG and CHR A20).
+ M215 {
+ /// NES 2.0 submapper 1 (UNL-8237A).
+ sub1: bool,
+ },
+ /// Mapper 197, `INES_Mapper_197.md`: the PPU-address wiring of
+ /// submappers 0-3 (any other value is taken as 0).
+ M197 {
+ /// NES 2.0 submapper.
+ sub: u8,
+ },
+ /// Mapper 165, `INES_Mapper_165.md`: MMC3 with MMC2-style CHR latches
+ /// and a 4 KiB CHR-RAM page.
+ M165,
+}
+
+impl ExtBoard {
+ const fn id(self) -> u16 {
+ match self {
+ Self::M114 { .. } => 114,
+ Self::M215 { .. } => 215,
+ Self::M197 { .. } => 197,
+ Self::M165 => 165,
+ }
+ }
+
+ const fn name(self) -> &'static str {
+ match self {
+ Self::M114 { sub1: false } => "SuperGame MMC3 scramble (114.0)",
+ Self::M114 { sub1: true } => "SuperGame MMC3 scramble (114.1)",
+ Self::M215 { sub1: false } => "Sugar Softec UNL-8237 (215.0)",
+ Self::M215 { sub1: true } => "Sugar Softec UNL-8237A (215.1)",
+ Self::M197 { sub: 1 } => "MMC3 2/4 KiB CHR (197.1)",
+ Self::M197 { sub: 2 } => "MMC3 2/4 KiB CHR (197.2)",
+ Self::M197 { sub: 3 } => "MMC3 2/4 KiB CHR + outer PRG (197.3)",
+ Self::M197 { .. } => "MMC3 2/4 KiB CHR (197.0)",
+ Self::M165 => "MMC3 + MMC2 latch (165)",
+ }
+ }
+
+ /// A one-byte tag for the save state, distinct per variant so a state
+ /// cannot be restored onto a different wiring of the same mapper.
+ const fn state_tag(self) -> u8 {
+ match self {
+ Self::M114 { sub1: false } => 0,
+ Self::M114 { sub1: true } => 1,
+ Self::M215 { sub1: false } => 2,
+ Self::M215 { sub1: true } => 3,
+ Self::M197 { sub } => 4 + sub,
+ Self::M165 => 8,
+ }
+ }
+}
+
+/// A register scramble: `addr[i]` is the MMC3 register (an `$E001`-masked
+/// address) that a write to the `i`-th of `$8000, $8001, $A000, $A001, $C000,
+/// $C001, $E000, $E001` reaches, and `index[v]` is the MMC3 bank-select index
+/// that a written index `v` (the low three bits of a `$8000` value) means.
+struct Scramble {
+ addr: [u16; 8],
+ index: [u8; 8],
+}
+
+/// Mapper 114 submapper 0, transcribed from the page's two tables.
+const M114_SUB0: Scramble = Scramble {
+ addr: [
+ 0xA001, 0xA000, 0x8000, 0xC000, 0x8001, 0xC001, 0xE000, 0xE001,
+ ],
+ index: [0, 3, 1, 5, 6, 7, 2, 4],
+};
+
+/// Mapper 114 submapper 1 ("different scrambling pattern").
+const M114_SUB1: Scramble = Scramble {
+ addr: [
+ 0xA001, 0x8001, 0x8000, 0xC001, 0xA000, 0xC000, 0xE000, 0xE001,
+ ],
+ index: [0, 2, 5, 3, 6, 1, 7, 4],
+};
+
+/// The identity scramble (mapper 215 patterns 0, 2, 5, 6 and 7).
+const IDENTITY: Scramble = Scramble {
+ addr: [
+ 0x8000, 0x8001, 0xA000, 0xA001, 0xC000, 0xC001, 0xE000, 0xE001,
+ ],
+ index: [0, 1, 2, 3, 4, 5, 6, 7],
+};
+
+/// Mapper 215's eight patterns, selected by `$5007`, transcribed from the
+/// page's address table (row = `$5007`, columns `$8000 ... $E001` written)
+/// and index table (row = `$5007`, column = the value written).
+const M215_SCRAMBLE: [Scramble; 8] = [
+ IDENTITY,
+ Scramble {
+ addr: [
+ 0xA001, 0xA000, 0x8000, 0xC000, 0x8001, 0xC001, 0xE000, 0xE001,
+ ],
+ index: [0, 2, 6, 1, 7, 3, 4, 5],
+ },
+ Scramble {
+ addr: IDENTITY.addr,
+ index: [0, 5, 4, 1, 7, 2, 6, 3],
+ },
+ Scramble {
+ addr: [
+ 0xC001, 0x8000, 0x8001, 0xA000, 0xA001, 0xE001, 0xE000, 0xC000,
+ ],
+ index: [0, 6, 3, 7, 5, 2, 4, 1],
+ },
+ Scramble {
+ addr: [
+ 0xA001, 0x8001, 0x8000, 0xC000, 0xA000, 0xC001, 0xE000, 0xE001,
+ ],
+ index: [0, 2, 5, 3, 6, 1, 7, 4],
+ },
+ IDENTITY,
+ IDENTITY,
+ IDENTITY,
+];
+
+/// Mapper 215's `$5001` power-up value, `$xF`: the last outer bank, so the
+/// MMC3's fixed `$E000` bank lands on the image's reset vector. The page
+/// leaves the high nibble undefined; it is taken as 0.
+const M215_OUTER_POWER_UP: u8 = 0x0F;
+
+/// Mapper 165's CHR-RAM: one 4 KiB page.
+const M165_CHR_RAM: usize = 0x1000;
+
+/// Where a pattern-table access lands.
+enum Chr {
+ /// A byte offset into CHR-ROM (or the whole-image CHR-RAM).
+ Rom(usize),
+ /// A byte offset into the board's separate CHR-RAM (165).
+ Ram(usize),
+}
+
+/// The position of an `$E001`-masked MMC3 address in a [`Scramble`] row.
+const fn scramble_slot(addr: u16) -> usize {
+ ((((addr >> 13) & 3) << 1) | (addr & 1)) as usize
+}
+
+/// Unscramble one CPU write: the real MMC3 register address, and the value
+/// with its index bits rewritten when that register is `$8000` (bank select).
+/// Bits 3-7 of a `$8000` value pass through ("bits 6 and 7 ... are unchanged";
+/// bits 3-5 are unused by the MMC3).
+fn unscramble(table: &Scramble, addr: u16, value: u8) -> (u16, u8) {
+ let real = table.addr[scramble_slot(addr)];
+ let value = if real == 0x8000 {
+ (value & 0xF8) | table.index[usize::from(value & 7)]
+ } else {
+ value
+ };
+ (real, value)
+}
+
+/// The NROM override shared by mappers 114 and 215: `M.S. BBBb`. With `M`
+/// (bit 7) set, the 16 KiB bank `BBBb` replaces both MMC3 halves; with `S`
+/// (bit 5) set, CPU A14 replaces `b` (NROM-256). Returns the 8 KiB bank, or
+/// `None` while the MMC3's own banks apply.
+fn nrom_override(reg: u8, addr: u16) -> Option {
+ if reg & 0x80 == 0 {
+ return None;
+ }
+ let mut bank16 = usize::from(reg & 0x0F);
+ if reg & 0x20 != 0 {
+ bank16 = (bank16 & !1) | usize::from((addr >> 14) & 1);
+ }
+ Some((bank16 << 1) | usize::from((addr >> 13) & 1))
+}
+
+/// An MMC3 board with a scramble or rewiring layer. See the module docs.
+pub struct Mmc3BoardExt {
+ board: ExtBoard,
+ core: Mmc3,
+ prg_rom: Box<[u8]>,
+ /// CHR-ROM, or 8 KiB of CHR-RAM when the image has none.
+ chr: Box<[u8]>,
+ chr_is_ram: bool,
+ /// Work RAM at `$6000-$7FFF`, only when a NES 2.0 header declares it.
+ wram: Box<[u8]>,
+ /// Board registers. 114: `regs[0]` = `$6000`, `regs[1]` = `$6001`.
+ /// 215: `regs[0]` = `$5000`, `regs[1]` = `$5001`, `regs[2]` = `$5007`.
+ /// 197.3: `regs[0]` = the `$6000` outer register.
+ regs: [u8; 4],
+ /// 165: the two CHR latches, `true` = `$FE` (index 0: `$0000-$0FFF`).
+ latch: [bool; 2],
+ /// 165: the 4 KiB CHR-RAM page; empty on every other board.
+ chr_ram: Box<[u8]>,
+}
+
+impl Mmc3BoardExt {
+ /// Construct a board. `wram_bytes` is the NES 2.0 PRG-RAM size, or 0.
+ ///
+ /// # Errors
+ ///
+ /// [`MapperError::Invalid`] when PRG is not a non-zero multiple of 8 KiB
+ /// or CHR is not a multiple of 1 KiB.
+ pub fn new(
+ board: ExtBoard,
+ prg_rom: Box<[u8]>,
+ chr_rom: Box<[u8]>,
+ mirroring: Mirroring,
+ wram_bytes: usize,
+ ) -> Result {
+ let id = board.id();
+ if prg_rom.is_empty() || !prg_rom.len().is_multiple_of(PRG_BANK_8K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {id} PRG-ROM size {} is not a non-zero multiple of 8 KiB",
+ prg_rom.len()
+ )));
+ }
+ if !chr_rom.len().is_multiple_of(CHR_BANK_1K) {
+ return Err(MapperError::Invalid(format!(
+ "mapper {id} CHR-ROM size {} is not a multiple of 1 KiB",
+ chr_rom.len()
+ )));
+ }
+ let chr_is_ram = chr_rom.is_empty();
+ let chr = if chr_is_ram {
+ vec![0u8; 8 * CHR_BANK_1K].into_boxed_slice()
+ } else {
+ chr_rom
+ };
+ // 114: "IRQ behavior resembles that of the MMC3A, i.e. a latch value
+ // of zero disables the IRQ, on which Aladdin depends."
+ let revision = match board {
+ ExtBoard::M114 { .. } => Mmc3Revision::Nec,
+ // 197: "PRG-ROM, nametable mirroring and IRQs function as in
+ // mapper 4."
+ // 165: "Register style, PRG, mirroring, ?and even IRQs? of MMC3".
+ ExtBoard::M215 { .. } | ExtBoard::M197 { .. } | ExtBoard::M165 => Mmc3Revision::Sharp,
+ };
+ // Unknown 197 submappers are wired as submapper 0.
+ let board = match board {
+ ExtBoard::M197 { sub } if sub > 3 => ExtBoard::M197 { sub: 0 },
+ b => b,
+ };
+ // 165: "SRAM enabling ... function as they do on your vanilla MMC3",
+ // so the board has the MMC3's 8 KiB of work RAM (the Fire Emblem
+ // translations save to it) even when an iNES 1.0 header says nothing.
+ let wram_bytes = if board == ExtBoard::M165 && wram_bytes == 0 {
+ 0x2000
+ } else {
+ wram_bytes
+ };
+ let mut regs = [0u8; 4];
+ if matches!(board, ExtBoard::M215 { .. }) {
+ regs[1] = M215_OUTER_POWER_UP;
+ }
+ Ok(Self {
+ board,
+ core: Mmc3::register_core(mirroring, revision),
+ prg_rom,
+ chr,
+ chr_is_ram,
+ wram: vec![0u8; wram_bytes].into_boxed_slice(),
+ regs,
+ latch: [false; 2],
+ chr_ram: vec![
+ 0u8;
+ if board == ExtBoard::M165 {
+ M165_CHR_RAM
+ } else {
+ 0
+ }
+ ]
+ .into_boxed_slice(),
+ })
+ }
+
+ /// The 8 KiB PRG bank for `addr` (`$8000-$FFFF`), before the mirror.
+ fn prg_bank(&self, addr: u16) -> usize {
+ let raw = self.core.prg_bank_raw(addr) as usize;
+ match self.board {
+ ExtBoard::M114 { .. } => nrom_override(self.regs[0], addr).unwrap_or(raw),
+ ExtBoard::M215 { sub1 } => {
+ let (mode, outer) = (self.regs[0], usize::from(self.regs[1]));
+ let inner = nrom_override(mode, addr).unwrap_or(raw);
+ // PRG A13-A17 inner; in 128 KiB mode A17 is `$5001` bit 4.
+ let inner = if mode & 0x40 != 0 {
+ (inner & 0x0F) | (((outer >> 4) & 1) << 4)
+ } else {
+ inner & 0x1F
+ };
+ // PRG A18-A19 (sub 0), A18-A20 (sub 1).
+ let hi = if sub1 {
+ (outer & 3) | (((outer >> 3) & 1) << 2)
+ } else {
+ outer & 3
+ };
+ inner | (hi << 5)
+ }
+ // 197.3: `S` (bit 4) set makes PRG A17 = `P` (bit 0).
+ ExtBoard::M197 { sub: 3 } if self.regs[0] & 0x10 != 0 => {
+ (raw & 0x0F) | (usize::from(self.regs[0] & 1) << 4)
+ }
+ ExtBoard::M197 { .. } | ExtBoard::M165 => raw,
+ }
+ }
+
+ /// The 1 KiB CHR-ROM bank for a pattern-table address, before the mirror.
+ fn chr_bank(&self, addr: u16) -> usize {
+ let raw = self.core.chr_bank_1k(addr);
+ match self.board {
+ // `$6001` bit 0 is CHR A18 over the MMC3's A10-A17.
+ ExtBoard::M114 { .. } => (raw & 0xFF) | (usize::from(self.regs[1] & 1) << 8),
+ ExtBoard::M215 { sub1 } => {
+ let outer = usize::from(self.regs[1]);
+ // CHR A10-A17 inner; in 128 KiB mode A17 is `$5001` bit 5.
+ let inner = if self.regs[0] & 0x40 != 0 {
+ (raw & 0x7F) | (((outer >> 5) & 1) << 7)
+ } else {
+ raw & 0xFF
+ };
+ // CHR A18-A19 (sub 0: bits 2-3), A18-A20 (sub 1: bits 1-3).
+ let hi = if sub1 {
+ (outer >> 1) & 7
+ } else {
+ (outer >> 2) & 3
+ };
+ inner | (hi << 8)
+ }
+ ExtBoard::M197 { sub } => {
+ // The address the MMC3 sees: PA10 = PPU A11, PA11 per the
+ // submapper's wiring, PA12 = PPU A12.
+ let a11 = (addr >> 11) & 1;
+ let pa11 = match sub {
+ 1 => 1,
+ 2 => a11,
+ _ => 0,
+ };
+ let seen = (addr & 0x1000) | (pa11 << 11) | (a11 << 10);
+ // MMC3 CHR A10-A17 drive ROM A11-A18; PPU A10 drives ROM A10.
+ ((self.core.chr_bank_1k(seen) & 0xFF) << 1) | usize::from((addr >> 10) & 1)
+ }
+ // 165 resolves through `m165_page`; this arm serves `chr_phys`'s
+ // ROM path only and is never reached for a RAM page.
+ ExtBoard::M165 => (self.m165_page(addr) << 2) | usize::from((addr >> 10) & 3),
+ }
+ }
+
+ /// Mapper 165: the 4 KiB page a pattern-table address selects, from the
+ /// latch of its half (R0 / R1 below `$1000`, R2 / R4 above).
+ fn m165_page(&self, addr: u16) -> usize {
+ let high = addr & 0x1000 != 0;
+ let reg = match (high, self.latch[usize::from(high)]) {
+ (false, false) => 0,
+ (false, true) => 1,
+ (true, false) => 2,
+ (true, true) => 4,
+ };
+ usize::from(self.core.bank_register(reg) >> 2)
+ }
+
+ /// Resolve a pattern-table address to CHR-ROM or the 165 CHR-RAM page.
+ fn chr_target(&self, addr: u16) -> Chr {
+ let addr = addr & 0x1FFF;
+ if self.board == ExtBoard::M165 && self.m165_page(addr) == 0 {
+ return Chr::Ram(usize::from(addr) & (M165_CHR_RAM - 1));
+ }
+ let bank = mirror_bank(self.chr_bank(addr), self.chr.len() / CHR_BANK_1K);
+ Chr::Rom(bank * CHR_BANK_1K + (usize::from(addr) & (CHR_BANK_1K - 1)))
+ }
+
+ /// Mapper 165's latch update, after the read that hit a trigger: the
+ /// MMC2's triggers (`MMC2.md`), latch 0 on `$0FD8` / `$0FE8` exactly and
+ /// latch 1 on `$1FD8-$1FDF` / `$1FE8-$1FEF`.
+ fn m165_update_latch(&mut self, addr: u16) {
+ match addr & 0x1FFF {
+ 0x0FD8 => self.latch[0] = false,
+ 0x0FE8 => self.latch[0] = true,
+ 0x1FD8..=0x1FDF => self.latch[1] = false,
+ 0x1FE8..=0x1FEF => self.latch[1] = true,
+ _ => {}
+ }
+ }
+
+ fn write_high(&mut self, addr: u16, value: u8) {
+ let (addr, value) = match self.board {
+ ExtBoard::M114 { sub1: false } => unscramble(&M114_SUB0, addr, value),
+ ExtBoard::M114 { sub1: true } => unscramble(&M114_SUB1, addr, value),
+ ExtBoard::M215 { .. } => {
+ unscramble(&M215_SCRAMBLE[usize::from(self.regs[2] & 7)], addr, value)
+ }
+ ExtBoard::M197 { .. } | ExtBoard::M165 => (addr, value),
+ };
+ self.core.cpu_write(addr, value);
+ }
+}
+
+impl Mapper for Mmc3BoardExt {
+ fn sram(&self) -> &[u8] {
+ &self.wram
+ }
+
+ fn sram_mut(&mut self) -> &mut [u8] {
+ &mut self.wram
+ }
+
+ fn caps(&self) -> MapperCaps {
+ MapperCaps::CYCLE_IRQ
+ }
+
+ fn reset(&mut self) {
+ // 215: "The Outer Bank Register is reset to its power-up value if a
+ // Reset has been detected via an interruption of the M2 signal."
+ if matches!(self.board, ExtBoard::M215 { .. }) {
+ self.regs[1] = M215_OUTER_POWER_UP;
+ }
+ }
+
+ fn cpu_read_unmapped(&self, addr: u16) -> bool {
+ match addr {
+ 0x4020..=0x5FFF => true,
+ 0x6000..=0x7FFF => self.wram.is_empty() || !self.core.prg_ram_enabled(),
+ _ => false,
+ }
+ }
+
+ fn cpu_read(&mut self, addr: u16) -> u8 {
+ match addr {
+ 0x8000..=0xFFFF => {
+ let bank = mirror_bank(self.prg_bank(addr), self.prg_rom.len() / PRG_BANK_8K);
+ self.prg_rom[bank * PRG_BANK_8K + (usize::from(addr) & 0x1FFF)]
+ }
+ 0x6000..=0x7FFF if !self.wram.is_empty() && self.core.prg_ram_enabled() => {
+ self.wram[usize::from(addr - 0x6000) % self.wram.len()]
+ }
+ _ => 0,
+ }
+ }
+
+ fn cpu_write(&mut self, addr: u16, value: u8) {
+ match (self.board, addr) {
+ (_, 0x8000..=0xFFFF) => self.write_high(addr, value),
+ (ExtBoard::M114 { .. }, 0x6000..=0x7FFF) => {
+ // Not on the WRAM interface: taken whatever `$A001` says.
+ if addr & 0xE001 == 0x6000 {
+ self.regs[0] = value;
+ } else {
+ self.regs[1] = value;
+ }
+ if !self.wram.is_empty() && self.core.prg_ram_writable() {
+ let len = self.wram.len();
+ self.wram[usize::from(addr - 0x6000) % len] = value;
+ }
+ }
+ (ExtBoard::M215 { .. }, 0x5000..=0x5FFF) => match addr & 0xF007 {
+ 0x5000 => self.regs[0] = value,
+ 0x5001 => self.regs[1] = value,
+ 0x5007 => self.regs[2] = value & 7,
+ _ => {}
+ },
+ (ExtBoard::M197 { sub: 3 }, 0x6000..=0x7FFF) if self.core.prg_ram_writable() => {
+ // On the WRAM interface: only while `$A001` allows a write.
+ self.regs[0] = value;
+ if !self.wram.is_empty() {
+ let len = self.wram.len();
+ self.wram[usize::from(addr - 0x6000) % len] = value;
+ }
+ }
+ (_, 0x6000..=0x7FFF) if !self.wram.is_empty() && self.core.prg_ram_writable() => {
+ let len = self.wram.len();
+ self.wram[usize::from(addr - 0x6000) % len] = value;
+ }
+ _ => {}
+ }
+ }
+
+ fn chr_reads_are_pure(&self) -> bool {
+ // 165: a read of tile $FD / $FE switches a CHR latch.
+ self.board != ExtBoard::M165
+ }
+
+ fn chr_phys(&self, addr: u16) -> Option {
+ match self.chr_target(addr) {
+ Chr::Rom(off) if !self.chr_is_ram => u32::try_from(off).ok(),
+ _ => None,
+ }
+ }
+
+ fn ppu_read(&mut self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr >= 0x2000 {
+ return self.core.ppu_read(addr);
+ }
+ let value = match self.chr_target(addr) {
+ Chr::Rom(off) => self.chr[off],
+ Chr::Ram(off) => self.chr_ram[off],
+ };
+ if self.board == ExtBoard::M165 {
+ self.m165_update_latch(addr);
+ }
+ value
+ }
+
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ if addr >= 0x2000 {
+ return self.core.ppu_peek(addr);
+ }
+ match self.chr_target(addr) {
+ Chr::Rom(off) => self.chr[off],
+ Chr::Ram(off) => self.chr_ram[off],
+ }
+ }
+
+ fn ppu_write(&mut self, addr: u16, value: u8) {
+ let addr = addr & 0x3FFF;
+ if addr >= 0x2000 {
+ self.core.ppu_write(addr, value);
+ return;
+ }
+ match self.chr_target(addr) {
+ Chr::Ram(off) => self.chr_ram[off] = value,
+ Chr::Rom(off) if self.chr_is_ram => self.chr[off] = value,
+ Chr::Rom(_) => {}
+ }
+ }
+
+ fn nametable_address(&self, addr: u16) -> u16 {
+ self.core.nametable_address(addr)
+ }
+
+ fn current_mirroring(&self) -> Mirroring {
+ self.core.current_mirroring()
+ }
+
+ fn notify_a12(&mut self, level: bool) {
+ self.core.notify_a12(level);
+ }
+
+ fn notify_a12_at_sub_dot(&mut self, level: bool, sub_dot: u8) {
+ self.core.notify_a12_at_sub_dot(level, sub_dot);
+ }
+
+ fn notify_cpu_cycle(&mut self) {
+ self.core.notify_cpu_cycle();
+ }
+
+ fn irq_pending(&self) -> bool {
+ self.core.irq_pending()
+ }
+
+ fn irq_acknowledge(&mut self) {
+ self.core.irq_acknowledge();
+ }
+
+ fn debug_info(&self) -> MapperDebugInfo {
+ let mut info = self.core.debug_info();
+ info.mapper_id = self.board.id();
+ info.name = self.board.name().to_string();
+ for (i, r) in self.regs.iter().enumerate() {
+ info.extra.push((format!("board{i}"), format!("{r:#04x}")));
+ }
+ if self.board == ExtBoard::M165 {
+ for (i, fe) in self.latch.iter().enumerate() {
+ let v = if *fe { "FE" } else { "FD" };
+ info.extra.push((format!("latch{i}"), v.to_string()));
+ }
+ }
+ info
+ }
+
+ fn save_state(&self) -> Vec {
+ let core = self.core.save_state();
+ let chr = if self.chr_is_ram { self.chr.len() } else { 0 };
+ let mut out =
+ Vec::with_capacity(HEAD + core.len() + chr + self.chr_ram.len() + self.wram.len());
+ out.push(SAVE_STATE_VERSION);
+ out.push(self.board.id() as u8);
+ out.push(self.board.state_tag());
+ out.extend_from_slice(&self.regs);
+ out.push(u8::from(self.latch[0]) | (u8::from(self.latch[1]) << 1));
+ out.extend_from_slice(&(core.len() as u32).to_le_bytes());
+ out.extend_from_slice(&core);
+ if self.chr_is_ram {
+ out.extend_from_slice(&self.chr);
+ }
+ out.extend_from_slice(&self.chr_ram);
+ out.extend_from_slice(&self.wram);
+ out
+ }
+
+ fn load_state(&mut self, data: &[u8]) -> Result<(), MapperError> {
+ if data.len() < HEAD {
+ return Err(MapperError::WrongLength {
+ expected: HEAD,
+ got: data.len(),
+ });
+ }
+ if data[0] != SAVE_STATE_VERSION {
+ return Err(MapperError::UnsupportedVersion(data[0]));
+ }
+ if u16::from(data[1]) != self.board.id() & 0xFF || data[2] != self.board.state_tag() {
+ return Err(MapperError::Invalid(format!(
+ "state is for mapper {} variant {}, this board is {}",
+ data[1],
+ data[2],
+ self.board.name()
+ )));
+ }
+ let core_len = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
+ let chr = if self.chr_is_ram { self.chr.len() } else { 0 };
+ // Checked: `core_len` comes from the blob, and a plain sum can wrap on
+ // a 32-bit target, pass the check, and panic at the slice.
+ let expected = HEAD
+ .checked_add(core_len)
+ .and_then(|n| n.checked_add(chr + self.chr_ram.len() + self.wram.len()));
+ if expected != Some(data.len()) {
+ return Err(MapperError::WrongLength {
+ expected: expected.unwrap_or(usize::MAX),
+ got: data.len(),
+ });
+ }
+ let mut cur = HEAD;
+ self.core.load_state(&data[cur..cur + core_len])?;
+ cur += core_len;
+ self.regs.copy_from_slice(&data[3..7]);
+ self.latch = [data[7] & 1 != 0, data[7] & 2 != 0];
+ if self.chr_is_ram {
+ self.chr.copy_from_slice(&data[cur..cur + chr]);
+ cur += chr;
+ }
+ let n = self.chr_ram.len();
+ self.chr_ram.copy_from_slice(&data[cur..cur + n]);
+ cur += n;
+ self.wram.copy_from_slice(&data[cur..]);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+#[path = "mmc3_boards_ext_tests.rs"]
+mod tests;
diff --git a/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs
new file mode 100644
index 000000000..eb508a6c0
--- /dev/null
+++ b/crates/rustynes-mappers/src/mmc3_boards_ext_tests.rs
@@ -0,0 +1,754 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+//! Register-decode tests for `mmc3_boards_ext.rs`.
+
+use alloc::vec::Vec;
+
+/// A NES 2.0 image for `mapper`.`sub`: every 8 KiB PRG bank and every 1 KiB
+/// CHR bank starts with its own bank number (low, high).
+fn nes2_image(mapper: u16, sub: u8, prg_16k: u8, chr_8k: u8) -> Vec {
+ let mut v = Vec::new();
+ v.extend_from_slice(b"NES\x1A");
+ v.push(prg_16k);
+ v.push(chr_8k);
+ v.push(((mapper & 0x0F) << 4) as u8);
+ v.push((mapper & 0xF0) as u8 | 0x08);
+ v.push(((mapper >> 8) & 0x0F) as u8 | (sub << 4));
+ v.extend_from_slice(&[0u8; 7]);
+ for b in 0..usize::from(prg_16k) * 2 {
+ let mut bank = alloc::vec![0u8; 0x2000];
+ bank[0] = b as u8;
+ bank[1] = (b >> 8) as u8;
+ v.extend_from_slice(&bank);
+ }
+ for b in 0..usize::from(chr_8k) * 8 {
+ let mut bank = alloc::vec![0u8; 0x400];
+ bank[0] = b as u8;
+ bank[1] = (b >> 8) as u8;
+ v.extend_from_slice(&bank);
+ }
+ v
+}
+
+#[test]
+fn m114_and_m182_parse() {
+ for (id, sub) in [(114u16, 0u8), (114, 1), (182, 0)] {
+ assert!(
+ crate::parse(&nes2_image(id, sub, 16, 32)).is_ok(),
+ "mapper {id}.{sub} must construct"
+ );
+ }
+}
+
+use super::*;
+
+fn prg_image(banks_8k: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks_8k * PRG_BANK_8K];
+ for b in 0..banks_8k {
+ v[b * PRG_BANK_8K] = b as u8;
+ v[b * PRG_BANK_8K + 1] = (b >> 8) as u8;
+ }
+ v.into_boxed_slice()
+}
+
+fn chr_image(banks_1k: usize) -> Box<[u8]> {
+ let mut v = vec![0u8; banks_1k * CHR_BANK_1K];
+ for b in 0..banks_1k {
+ v[b * CHR_BANK_1K] = b as u8;
+ v[b * CHR_BANK_1K + 1] = (b >> 8) as u8;
+ }
+ v.into_boxed_slice()
+}
+
+fn board(kind: ExtBoard, prg_banks: usize, chr_banks: usize) -> Mmc3BoardExt {
+ Mmc3BoardExt::new(
+ kind,
+ prg_image(prg_banks),
+ chr_image(chr_banks),
+ Mirroring::Vertical,
+ 0,
+ )
+ .expect("valid sizes")
+}
+
+fn prg_at(m: &mut Mmc3BoardExt, addr: u16) -> usize {
+ m.cpu_read(addr) as usize | (m.cpu_read(addr + 1) as usize) << 8
+}
+
+fn chr_at(m: &mut Mmc3BoardExt, addr: u16) -> usize {
+ m.ppu_read(addr) as usize | (m.ppu_read(addr + 1) as usize) << 8
+}
+
+/// One filtered A12 rise (the MMC3 needs three M2 cycles of A12 low).
+fn clock_a12(m: &mut Mmc3BoardExt) {
+ m.notify_a12(false);
+ for _ in 0..4 {
+ m.notify_cpu_cycle();
+ }
+ m.notify_a12(true);
+ // The MMC3 core asserts a counter-zero IRQ one CPU cycle after the
+ // rise (`irq_assert_pending_next_cycle`); let that cycle pass.
+ m.notify_cpu_cycle();
+}
+
+const M114_0: ExtBoard = ExtBoard::M114 { sub1: false };
+const M114_1: ExtBoard = ExtBoard::M114 { sub1: true };
+
+// ---------------------------------------------------------------------------
+// Mapper 114 (`INES_Mapper_114.md`).
+// ---------------------------------------------------------------------------
+
+/// Submapper 0: bank select is written at `$A000`, bank data at `$C000`, and
+/// the written index `w` means MMC3 register `0 3 1 5 6 7 2 4`[w]. Each case
+/// is a literal row of the page's table, not a lookup in the code's.
+#[test]
+fn m114_sub0_index_and_address_scramble_follow_the_page() {
+ // (written index, MMC3 register meant, where to observe it)
+ for (written, reg, window, is_prg) in [
+ (4u8, 6u8, 0x8000u16, true),
+ (5, 7, 0xA000, true),
+ (0, 0, 0x0000, false),
+ (2, 1, 0x0800, false),
+ (6, 2, 0x1000, false),
+ (1, 3, 0x1400, false),
+ (7, 4, 0x1800, false),
+ (3, 5, 0x1C00, false),
+ ] {
+ let mut m = board(M114_0, 32, 256);
+ m.cpu_write(0xA000, written); // real $8000
+ m.cpu_write(0xC000, 0x12); // real $8001
+ let got = if is_prg {
+ prg_at(&mut m, window)
+ } else {
+ chr_at(&mut m, window)
+ };
+ assert_eq!(got, 0x12, "written index {written} must reach R{reg}");
+ // The plain MMC3 addresses do not select banks on this board:
+ // `$8000` is `$A001` and `$8001` is `$A000` (mirroring).
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x03);
+ assert_eq!(
+ m.current_mirroring(),
+ Mirroring::Horizontal,
+ "$8001 is $A000"
+ );
+ }
+}
+
+/// Submapper 0: the bank-select mode bits (6 and 7) pass through unchanged,
+/// so PRG mode 1 moves R6 to `$C000` and CHR mode 1 moves R2 to `$0000`.
+#[test]
+fn m114_sub0_mode_bits_are_not_scrambled() {
+ let mut m = board(M114_0, 32, 256);
+ m.cpu_write(0xA000, 0x40 | 4); // PRG mode 1, R6
+ m.cpu_write(0xC000, 0x09);
+ assert_eq!(prg_at(&mut m, 0xC000), 9);
+ assert_eq!(prg_at(&mut m, 0x8000), 30, "second-last bank at $8000");
+ m.cpu_write(0xA000, 0x80 | 6); // CHR mode 1, R2
+ m.cpu_write(0xC000, 0x21);
+ assert_eq!(chr_at(&mut m, 0x0000), 0x21);
+}
+
+/// Submapper 0 IRQ: latch at `$A001` (real `$C000`), reload at `$C001`,
+/// disable/enable at `$E000`/`$E001` (unscrambled). The MMC3A behaviour the
+/// page names: a latch of 0 never raises the IRQ.
+#[test]
+fn m114_sub0_irq_registers_and_mmc3a_zero_latch() {
+ let mut m = board(M114_0, 32, 256);
+ m.cpu_write(0xA001, 2); // latch
+ m.cpu_write(0xC001, 0); // reload
+ m.cpu_write(0xE001, 0); // enable
+ clock_a12(&mut m); // reload -> 2
+ clock_a12(&mut m); // 1
+ assert!(!m.irq_pending());
+ clock_a12(&mut m); // 0 -> IRQ
+ assert!(m.irq_pending(), "latch 2 fires on the third clock");
+ m.cpu_write(0xE000, 0); // disable + acknowledge
+ assert!(!m.irq_pending());
+
+ let mut m = board(M114_0, 32, 256);
+ m.cpu_write(0xA001, 0);
+ m.cpu_write(0xC001, 0);
+ m.cpu_write(0xE001, 0);
+ // The `$C001` reload itself raises one IRQ on both revisions
+ // (`MMC3.md`: the alternate revision checks the 1->0 transition
+ // "whether from decrementing or reloading"); acknowledge it.
+ clock_a12(&mut m);
+ m.cpu_write(0xE000, 0);
+ m.cpu_write(0xE001, 0);
+ for _ in 0..8 {
+ clock_a12(&mut m);
+ }
+ assert!(!m.irq_pending(), "MMC3A: a latch of 0 disables the IRQ");
+}
+
+/// Submapper 1: `$8000`→`$A001`, `$8001`→`$8001`, `$A000`→`$8000`,
+/// `$A001`→`$C001`, `$C000`→`$A000`, `$C001`→`$C000`; written index `w`
+/// means `0 2 5 3 6 1 7 4`[w].
+#[test]
+fn m114_sub1_scramble_follows_the_page() {
+ for (written, window, is_prg) in [
+ (4u8, 0x8000u16, true), // R6
+ (6, 0xA000, true), // R7
+ (0, 0x0000, false), // R0
+ (5, 0x0800, false), // R1
+ (1, 0x1000, false), // R2
+ (3, 0x1400, false), // R3
+ (7, 0x1800, false), // R4
+ (2, 0x1C00, false), // R5
+ ] {
+ let mut m = board(M114_1, 32, 256);
+ m.cpu_write(0xA000, written);
+ m.cpu_write(0x8001, 0x0A);
+ let got = if is_prg {
+ prg_at(&mut m, window)
+ } else {
+ chr_at(&mut m, window)
+ };
+ assert_eq!(got, 0x0A, "written index {written} at {window:#06x}");
+ }
+ let mut m = board(M114_1, 32, 256);
+ m.cpu_write(0xC000, 1);
+ assert_eq!(
+ m.current_mirroring(),
+ Mirroring::Horizontal,
+ "$C000 is $A000"
+ );
+ // IRQ latch at `$C001` (real `$C000`), reload at `$A001` (real `$C001`).
+ m.cpu_write(0xC001, 1);
+ m.cpu_write(0xA001, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ clock_a12(&mut m);
+ assert!(m.irq_pending(), "latch 1 fires on the second clock");
+}
+
+/// `$6000` `M.S. BBBb`: with M set, a 16 KiB bank replaces both halves
+/// (NROM-128); with S also set, CPU A14 replaces bit 0 (NROM-256).
+#[test]
+fn m114_nrom_override() {
+ let mut m = board(M114_0, 32, 256);
+ m.cpu_write(0x6000, 0x80 | 0x03);
+ for (addr, bank) in [(0x8000, 6), (0xA000, 7), (0xC000, 6), (0xE000, 7)] {
+ assert_eq!(prg_at(&mut m, addr), bank, "NROM-128 at {addr:#06x}");
+ }
+ m.cpu_write(0x6000, 0xA0 | 0x03);
+ for (addr, bank) in [(0x8000, 4), (0xA000, 5), (0xC000, 6), (0xE000, 7)] {
+ assert_eq!(prg_at(&mut m, addr), bank, "NROM-256 at {addr:#06x}");
+ }
+ m.cpu_write(0x6000, 0x23); // M clear: MMC3 banks, bits 0-3/5 ignored.
+ assert_eq!(prg_at(&mut m, 0xE000), 31);
+ assert_eq!(prg_at(&mut m, 0xC000), 30);
+}
+
+/// `$6001` bit 0 is CHR A18, and both `$600x` registers work with the
+/// MMC3's WRAM disabled ("not connected to the MMC3 clone's WRAM
+/// interface").
+#[test]
+fn m114_outer_chr_bank_ignores_the_wram_bits() {
+ let mut m = board(M114_0, 32, 512);
+ m.cpu_write(0x8000, 0x00); // real $A001: WRAM disabled
+ m.cpu_write(0xA000, 6); // R2
+ m.cpu_write(0xC000, 0x05);
+ m.cpu_write(0x6001, 0x01);
+ assert_eq!(chr_at(&mut m, 0x1000), 0x105);
+ m.cpu_write(0x6001, 0x00);
+ assert_eq!(chr_at(&mut m, 0x1000), 0x005);
+ m.cpu_write(0x6000, 0x80);
+ assert_eq!(prg_at(&mut m, 0xC000), 0, "$6000 taken with WRAM disabled");
+}
+
+/// 182 is dispatched as 114 submapper 0; 114 submapper 1 is its own variant.
+#[test]
+fn m182_is_m114_submapper_0() {
+ let (_, m) = crate::parse(&nes2_image(182, 0, 16, 32)).expect("182");
+ assert_eq!(m.debug_info().mapper_id, 114);
+ assert!(m.debug_info().name.contains("114.0"));
+ let (_, m) = crate::parse(&nes2_image(114, 1, 16, 32)).expect("114.1");
+ assert!(m.debug_info().name.contains("114.1"));
+}
+
+/// The state round-trips, and a state for one scramble variant does not load
+/// onto the other.
+#[test]
+fn m114_state_round_trip_and_variant_refusal() {
+ let mut m = board(M114_0, 32, 256);
+ m.cpu_write(0x6000, 0xA5);
+ m.cpu_write(0x6001, 0x01);
+ m.cpu_write(0xA000, 4);
+ m.cpu_write(0xC000, 0x07);
+ let blob = m.save_state();
+ let mut n = board(M114_0, 32, 256);
+ n.load_state(&blob).expect("same variant");
+ assert_eq!(n.save_state(), blob);
+ assert!(board(M114_1, 32, 256).load_state(&blob).is_err());
+ let mut bad = blob.clone();
+ bad[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert!(board(M114_0, 32, 256).load_state(&bad).is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Mapper 215 (`INES_Mapper_215.md`).
+// ---------------------------------------------------------------------------
+
+#[test]
+fn m215_parses() {
+ for sub in [0u8, 1] {
+ assert!(
+ crate::parse(&nes2_image(215, sub, 32, 64)).is_ok(),
+ "mapper 215.{sub} must construct"
+ );
+ }
+}
+
+const M215_0: ExtBoard = ExtBoard::M215 { sub1: false };
+const M215_1: ExtBoard = ExtBoard::M215 { sub1: true };
+
+/// Each `$5007` pattern, pinned to literal cells of the page's two tables:
+/// where the bank select and bank data must be written, and which register
+/// a written index then reaches.
+#[test]
+fn m215_scramble_patterns_follow_the_page() {
+ // (pattern, select addr, data addr, written index, observe at, is PRG)
+ for (pat, sel, data, written, window, is_prg) in [
+ // 0, 5, 6, 7: the identity. Index 6 is R6.
+ (0u8, 0x8000u16, 0x8001u16, 6u8, 0x8000u16, true),
+ (5, 0x8000, 0x8001, 6, 0x8000, true),
+ (6, 0x8000, 0x8001, 6, 0x8000, true),
+ (7, 0x8000, 0x8001, 6, 0x8000, true),
+ // 1: `$A000` is `$8000`, `$C000` is `$8001`; written 4 means 7.
+ (1, 0xA000, 0xC000, 4, 0xA000, true),
+ // 1: written 2 means 6.
+ (1, 0xA000, 0xC000, 2, 0x8000, true),
+ // 2: addresses unscrambled; written 1 means 5 (R5 at `$1C00`).
+ (2, 0x8000, 0x8001, 1, 0x1C00, false),
+ // 3: `$8001` is `$8000`, `$A000` is `$8001`; written 1 means 6.
+ (3, 0x8001, 0xA000, 1, 0x8000, true),
+ // 3: written 4 means 5.
+ (3, 0x8001, 0xA000, 4, 0x1C00, false),
+ // 4: `$A000` is `$8000`, `$8001` stays `$8001`; written 4 means 6.
+ (4, 0xA000, 0x8001, 4, 0x8000, true),
+ // 4: written 1 means 2 (R2 at `$1000`).
+ (4, 0xA000, 0x8001, 1, 0x1000, false),
+ ] {
+ let mut m = board(M215_0, 32, 256);
+ m.cpu_write(0x5007, pat);
+ m.cpu_write(sel, written);
+ m.cpu_write(data, 0x0D);
+ let got = if is_prg {
+ prg_at(&mut m, window)
+ } else {
+ chr_at(&mut m, window)
+ };
+ assert_eq!(got, 0x0D, "pattern {pat}, written index {written}");
+ }
+}
+
+/// Pattern 3 moves the IRQ registers: `$8000` is `$C001` (reload), `$E001`
+/// is `$C000` (latch), `$C001` is `$E001` (enable). Pattern 1 moves the
+/// mirroring register to `$8001` (`$A000`).
+#[test]
+fn m215_scramble_moves_irq_and_mirroring_registers() {
+ let mut m = board(M215_0, 32, 256);
+ m.cpu_write(0x5007, 3);
+ m.cpu_write(0xE001, 1); // latch = 1
+ m.cpu_write(0x8000, 0); // reload
+ m.cpu_write(0xC001, 0); // enable
+ clock_a12(&mut m);
+ clock_a12(&mut m);
+ assert!(
+ m.irq_pending(),
+ "pattern 3: latch 1 fires on the second clock"
+ );
+
+ let mut m = board(M215_0, 32, 256);
+ m.cpu_write(0x5007, 1);
+ m.cpu_write(0x8001, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+}
+
+/// The page gives no IRQ revision; the MMC3 default (Sharp) applies, so a
+/// latch of 0 fires on every clock.
+#[test]
+fn m215_irq_is_the_sharp_mmc3() {
+ let mut m = board(M215_0, 32, 256);
+ m.cpu_write(0xC000, 0);
+ m.cpu_write(0xC001, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ m.cpu_write(0xE000, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ assert!(m.irq_pending(), "Sharp: a latch of 0 fires every clock");
+}
+
+/// `$5001` powers up as `$xF` (the last outer bank: the reset vector is
+/// reachable), a console reset restores it, and `$5000` / `$5007` keep their
+/// values across the reset.
+#[test]
+fn m215_outer_bank_power_up_and_reset() {
+ let mut m = board(M215_0, 128, 1024);
+ assert_eq!(prg_at(&mut m, 0xE000), 127, "power-up: last 256 KiB");
+ m.cpu_write(0x5001, 0x00);
+ m.cpu_write(0x5000, 0x80);
+ m.cpu_write(0x5007, 0x04);
+ assert_eq!(prg_at(&mut m, 0xE000), 1, "outer 0, NROM bank 0");
+ m.reset();
+ assert_eq!(m.regs[1], 0x0F, "$5001 back to its power-up value");
+ assert_eq!(m.regs[0], 0x80, "$5000 kept");
+ assert_eq!(m.regs[2], 0x04, "$5007 kept");
+}
+
+/// `$5000` is 114's NROM override (mask `$F007`, so `$5FF8` reaches it), and
+/// bit 6 makes PRG A17 / CHR A17 come from `$5001` bits 4 / 5.
+#[test]
+fn m215_nrom_override_and_128k_mode() {
+ let mut m = board(M215_0, 128, 1024);
+ m.cpu_write(0x5001, 0x00);
+ m.cpu_write(0x5FF8, 0xA0 | 0x05); // NROM-256, bank 5 -> 4/5
+ assert_eq!(prg_at(&mut m, 0x8000), 8);
+ assert_eq!(prg_at(&mut m, 0xC000), 10);
+ m.cpu_write(0x5000, 0x00);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x13); // MMC3 PRG A17 set
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 0x85); // MMC3 CHR A17 set
+ assert_eq!(prg_at(&mut m, 0x8000), 0x13, "256 KiB mode: MMC3 A17");
+ assert_eq!(chr_at(&mut m, 0x1000), 0x85);
+ m.cpu_write(0x5000, 0x40); // 128 KiB mode, $5001 bits 4/5 clear
+ assert_eq!(prg_at(&mut m, 0x8000), 0x03, "A17 from $5001 bit 4 (0)");
+ assert_eq!(chr_at(&mut m, 0x1000), 0x05, "A17 from $5001 bit 5 (0)");
+ m.cpu_write(0x5001, 0x30);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x13, "A17 from $5001 bit 4 (1)");
+ assert_eq!(chr_at(&mut m, 0x1000), 0x85, "A17 from $5001 bit 5 (1)");
+}
+
+/// The outer bank layouts: submapper 0 `..cp CCPP`, submapper 1 `..cp P.PP`
+/// over `CCC.` (PRG A20 = bit 3, CHR A18-A20 = bits 1-3).
+#[test]
+fn m215_outer_bank_layouts() {
+ // 2 MiB PRG (256 x 8 KiB), 2 MiB CHR (2048 x 1 KiB).
+ let mut m = board(M215_0, 256, 2048);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x01);
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 0x01);
+ m.cpu_write(0x5001, 0x0E); // PRG A19 (bit 1), CHR A18-A19 = 3
+ assert_eq!(prg_at(&mut m, 0x8000), 0x01 | (2 << 5));
+ assert_eq!(chr_at(&mut m, 0x1000), 0x01 | (3 << 8));
+ m.cpu_write(0x5001, 0x08);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x01, "sub 0: bit 3 is CHR only");
+
+ let mut m = board(M215_1, 256, 2048);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x01);
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 0x01);
+ m.cpu_write(0x5001, 0x08); // PRG A20, CHR A20
+ assert_eq!(prg_at(&mut m, 0x8000), 0x01 | (4 << 5));
+ assert_eq!(chr_at(&mut m, 0x1000), 0x01 | (4 << 8));
+ m.cpu_write(0x5001, 0x03); // PRG A18-A19, CHR A18
+ assert_eq!(prg_at(&mut m, 0x8000), 0x01 | (3 << 5));
+ assert_eq!(chr_at(&mut m, 0x1000), 0x01 | (1 << 8));
+}
+
+#[test]
+fn m215_state_round_trip_and_variant_refusal() {
+ let mut m = board(M215_0, 64, 512);
+ m.cpu_write(0x5000, 0x45);
+ m.cpu_write(0x5001, 0x21);
+ m.cpu_write(0x5007, 0x03);
+ let blob = m.save_state();
+ let mut n = board(M215_0, 64, 512);
+ n.load_state(&blob).expect("same variant");
+ assert_eq!(n.save_state(), blob);
+ assert!(board(M215_1, 64, 512).load_state(&blob).is_err());
+ assert!(board(M114_0, 64, 512).load_state(&blob).is_err());
+}
+
+// ---------------------------------------------------------------------------
+// Mapper 197 (`INES_Mapper_197.md`).
+// ---------------------------------------------------------------------------
+
+#[test]
+fn m197_parses() {
+ for sub in [0u8, 1, 2, 3] {
+ assert!(
+ crate::parse(&nes2_image(197, sub, 16, 64)).is_ok(),
+ "mapper 197.{sub} must construct"
+ );
+ }
+}
+
+/// Load R0-R5 with `0x11, 0x21, 0x30, 0x40, 0x50, 0x60` (R0/R1 odd, so the
+/// page's `AND $FE` / `OR $01` are visible) and R6 with 3.
+fn m197_load_regs(m: &mut Mmc3BoardExt, mode: u8) {
+ for (r, v) in [
+ (0u8, 0x11u8),
+ (1, 0x21),
+ (2, 0x30),
+ (3, 0x40),
+ (4, 0x50),
+ (5, 0x60),
+ (6, 3),
+ ] {
+ m.cpu_write(0x8000, mode | r);
+ m.cpu_write(0x8001, v);
+ }
+}
+
+/// The page's "Real PPU Addr -> MMC3 reg" table for each submapper, row by
+/// row. A row's MMC3 register value lands on CHR-ROM A11-A18 and PPU A10 on
+/// A10, so the 1 KiB ROM bank is `value * 2 + A10`.
+#[test]
+fn m197_chr_wiring_follows_the_page_tables() {
+ let (r0, r1, r2, r3, r4, r5) = (0x11usize, 0x21, 0x30, 0x40, 0x50, 0x60);
+ let tables: [(u8, [usize; 4]); 3] = [
+ // sub 0: $0000/$0400 R0&$FE, $0800/$0C00 R0|1, $1000/$1400 R2, $1800/$1C00 R3.
+ (0, [r0 & 0xFE, r0 | 1, r2, r3]),
+ // sub 1: R1&$FE, R1|1, R4, R5.
+ (1, [r1 & 0xFE, r1 | 1, r4, r5]),
+ // sub 2: R0&$FE, R1|1, R2, R5.
+ (2, [r0 & 0xFE, r1 | 1, r2, r5]),
+ ];
+ for (sub, regs) in tables {
+ let mut m = board(ExtBoard::M197 { sub }, 16, 512);
+ m197_load_regs(&mut m, 0);
+ for (i, &v) in regs.iter().enumerate() {
+ let base = (i as u16) * 0x800;
+ assert_eq!(chr_at(&mut m, base), v * 2, "sub {sub} at {base:#06x}");
+ assert_eq!(
+ chr_at(&mut m, base + 0x400),
+ v * 2 + 1,
+ "sub {sub} at {:#06x}",
+ base + 0x400
+ );
+ }
+ }
+}
+
+/// `$8000` bit 7 is not on the page, but the MMC3 still has it and PA12 is
+/// PPU A12, so it swaps which registers each pattern table sees: inverted,
+/// submapper 0's `$0000` reads R2 and `$1000` reads R0.
+#[test]
+fn m197_chr_inversion_still_swaps_pattern_tables() {
+ let mut m = board(ExtBoard::M197 { sub: 0 }, 16, 512);
+ m197_load_regs(&mut m, 0x80);
+ assert_eq!(chr_at(&mut m, 0x0000), 0x30 * 2);
+ assert_eq!(chr_at(&mut m, 0x0800), 0x40 * 2);
+ assert_eq!(chr_at(&mut m, 0x1000), 0x10 * 2);
+ assert_eq!(chr_at(&mut m, 0x1800), 0x11 * 2);
+}
+
+/// "PRG-ROM, nametable mirroring and IRQs function as in mapper 4": R6 at
+/// `$8000`, `$A000` mirroring, and the Sharp IRQ (a latch of 0 fires every
+/// clock).
+#[test]
+fn m197_prg_mirroring_and_irq_are_mapper_4() {
+ let mut m = board(ExtBoard::M197 { sub: 0 }, 16, 512);
+ m197_load_regs(&mut m, 0);
+ assert_eq!(prg_at(&mut m, 0x8000), 3);
+ assert_eq!(prg_at(&mut m, 0xE000), 15);
+ m.cpu_write(0xA000, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ m.cpu_write(0xC000, 0);
+ m.cpu_write(0xC001, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ m.cpu_write(0xE000, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ assert!(m.irq_pending());
+}
+
+/// Submapper 3: `$6000-$7FFF` `.... S..P` on the WRAM interface. With S set
+/// PRG A17 = P; with S clear it is the MMC3's. A write while `$A001` forbids
+/// it is dropped. Submapper 0 has no such register.
+#[test]
+fn m197_sub3_outer_prg_register() {
+ let mut m = board(ExtBoard::M197 { sub: 3 }, 32, 512);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x13);
+ m.cpu_write(0xA001, 0x00); // WRAM disabled
+ m.cpu_write(0x6000, 0x10);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x13, "dropped while disabled");
+ m.cpu_write(0xA001, 0xC0); // enabled, write-protected
+ m.cpu_write(0x7FFF, 0x10);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x13, "dropped while protected");
+ m.cpu_write(0xA001, 0x80);
+ m.cpu_write(0x7FFF, 0x10);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x03, "S=1, P=0");
+ m.cpu_write(0x6000, 0x11);
+ m.cpu_write(0x8001, 0x03);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x13, "S=1, P=1");
+ m.cpu_write(0x6000, 0x01);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x03, "S=0: the MMC3's A17");
+
+ let mut m = board(ExtBoard::M197 { sub: 0 }, 32, 512);
+ m.cpu_write(0x8000, 6);
+ m.cpu_write(0x8001, 0x03);
+ m.cpu_write(0xA001, 0x80);
+ m.cpu_write(0x6000, 0x11);
+ assert_eq!(prg_at(&mut m, 0x8000), 0x03, "sub 0 has no outer register");
+}
+
+#[test]
+fn m197_state_round_trip_and_variant_refusal() {
+ let mut m = board(ExtBoard::M197 { sub: 3 }, 32, 512);
+ m.cpu_write(0xA001, 0x80);
+ m.cpu_write(0x6000, 0x11);
+ m197_load_regs(&mut m, 0);
+ let blob = m.save_state();
+ let mut n = board(ExtBoard::M197 { sub: 3 }, 32, 512);
+ n.load_state(&blob).expect("same variant");
+ assert_eq!(n.save_state(), blob);
+ assert!(
+ board(ExtBoard::M197 { sub: 0 }, 32, 512)
+ .load_state(&blob)
+ .is_err()
+ );
+ // An unknown submapper is wired as submapper 0.
+ let m = board(ExtBoard::M197 { sub: 9 }, 32, 512);
+ assert_eq!(m.board, ExtBoard::M197 { sub: 0 });
+}
+
+// ---------------------------------------------------------------------------
+// Mapper 165 (`INES_Mapper_165.md`, Disch's notes).
+// ---------------------------------------------------------------------------
+
+#[test]
+fn m165_parses() {
+ assert!(
+ crate::parse(&nes2_image(165, 0, 32, 16)).is_ok(),
+ "mapper 165 must construct"
+ );
+}
+
+/// Load R0 = `$08`, R1 = `$0C`, R2 = `$10`, R4 = `$14` (4 KiB pages 2, 3, 4,
+/// 5 at a page = R >> 2) and R6 = 3.
+fn m165_load_regs(m: &mut Mmc3BoardExt) {
+ for (r, v) in [(0u8, 0x08u8), (1, 0x0C), (2, 0x10), (4, 0x14), (6, 3)] {
+ m.cpu_write(0x8000, r);
+ m.cpu_write(0x8001, v);
+ }
+}
+
+/// "`$0000`: R0 or R1, `$1000`: R2 or R4", picked by the MMC2 latches; a
+/// page is the register over 4, so 1 KiB bank = page * 4 + slot. The latch
+/// switches after the read that hits the trigger (`MMC2.md`), so that read
+/// still comes from the old page.
+#[test]
+fn m165_latches_select_the_mmc2_style_pages() {
+ let mut m = board(ExtBoard::M165, 32, 128);
+ m165_load_regs(&mut m);
+ // Power-on latches are $FD: R0 and R2.
+ assert_eq!(chr_at(&mut m, 0x0000), 8);
+ assert_eq!(chr_at(&mut m, 0x0C00), 11);
+ assert_eq!(chr_at(&mut m, 0x1000), 16);
+ // $0FE8 sets latch 0 to $FE: the read itself is from R0's page.
+ assert_eq!(m.ppu_read(0x0FE8), m.chr[11 * CHR_BANK_1K + 0x3E8]);
+ assert_eq!(chr_at(&mut m, 0x0000), 12, "R1 after $0FE8");
+ // $0FD8 sets it back.
+ m.ppu_read(0x0FD8);
+ assert_eq!(chr_at(&mut m, 0x0000), 8, "R0 after $0FD8");
+ // Latch 1 responds to the whole $1FE8-$1FEF / $1FD8-$1FDF ranges.
+ m.ppu_read(0x1FEC);
+ assert_eq!(chr_at(&mut m, 0x1000), 20, "R4 after $1FEC");
+ assert_eq!(chr_at(&mut m, 0x0000), 8, "latch 0 untouched");
+ m.ppu_read(0x1FDF);
+ assert_eq!(chr_at(&mut m, 0x1000), 16, "R2 after $1FDF");
+}
+
+/// Latch 0 "only responds to one address" (`MMC2.md`): `$0FE9` and `$0FE0`
+/// do not switch it.
+#[test]
+fn m165_latch_0_triggers_on_exact_addresses_only() {
+ let mut m = board(ExtBoard::M165, 32, 128);
+ m165_load_regs(&mut m);
+ m.ppu_read(0x0FE9);
+ m.ppu_read(0x0FE0);
+ assert_eq!(chr_at(&mut m, 0x0000), 8);
+ m.ppu_read(0x0FE8);
+ assert_eq!(chr_at(&mut m, 0x0000), 12);
+}
+
+/// "CHR page 0 is CHR-RAM": one 4 KiB page, the same RAM in either window;
+/// a ROM page ignores writes.
+#[test]
+fn m165_page_0_is_chr_ram() {
+ let mut m = board(ExtBoard::M165, 32, 128);
+ m165_load_regs(&mut m);
+ m.ppu_write(0x0123, 0x5A);
+ assert_ne!(m.ppu_read(0x0123), 0x5A, "page 2 is ROM");
+ m.cpu_write(0x8000, 0);
+ m.cpu_write(0x8001, 0x03); // R0 = 3: page 0
+ m.ppu_write(0x0123, 0x5A);
+ assert_eq!(m.ppu_read(0x0123), 0x5A);
+ m.cpu_write(0x8000, 2);
+ m.cpu_write(0x8001, 0x00); // R2 = 0: page 0 at $1000 too
+ assert_eq!(m.ppu_read(0x1123), 0x5A, "the same 4 KiB page");
+ assert_eq!(m.chr_phys(0x1123), None, "RAM has no ROM offset");
+}
+
+/// The page ignores `$8000` bit 7 (CHR inversion): the notes' diagram has
+/// R0/R1 at `$0000` and R2/R4 at `$1000` unconditionally.
+#[test]
+fn m165_chr_inversion_has_no_effect() {
+ let mut m = board(ExtBoard::M165, 32, 128);
+ m165_load_regs(&mut m);
+ m.cpu_write(0x8000, 0x80);
+ assert_eq!(chr_at(&mut m, 0x0000), 8);
+ assert_eq!(chr_at(&mut m, 0x1000), 16);
+}
+
+/// PRG, mirroring, SRAM enable and IRQ are the MMC3's; the board has 8 KiB
+/// of work RAM without being told.
+#[test]
+fn m165_prg_wram_mirroring_and_irq_are_mmc3() {
+ let mut m = board(ExtBoard::M165, 32, 128);
+ m165_load_regs(&mut m);
+ assert_eq!(prg_at(&mut m, 0x8000), 3);
+ assert_eq!(prg_at(&mut m, 0xE000), 31);
+ m.cpu_write(0xA000, 1);
+ assert_eq!(m.current_mirroring(), Mirroring::Horizontal);
+ assert_eq!(m.sram().len(), 0x2000);
+ m.cpu_write(0xA001, 0x80);
+ m.cpu_write(0x7FFF, 0x77);
+ assert_eq!(m.cpu_read(0x7FFF), 0x77);
+ m.cpu_write(0xA001, 0x00);
+ assert!(m.cpu_read_unmapped(0x7FFF), "SRAM disabled by $A001");
+ m.cpu_write(0xC000, 1);
+ m.cpu_write(0xC001, 0);
+ m.cpu_write(0xE001, 0);
+ clock_a12(&mut m);
+ clock_a12(&mut m);
+ assert!(m.irq_pending());
+}
+
+/// A CHR read can switch banks, so the board says so, and the latches and
+/// the CHR-RAM page are in the save state.
+#[test]
+fn m165_impure_reads_and_state_round_trip() {
+ let mut m = board(ExtBoard::M165, 32, 128);
+ assert!(!m.chr_reads_are_pure());
+ assert!(board(M114_0, 32, 128).chr_reads_are_pure());
+ m165_load_regs(&mut m);
+ m.ppu_read(0x0FE8);
+ m.ppu_read(0x1FE8);
+ m.cpu_write(0x8000, 1);
+ m.cpu_write(0x8001, 0x00); // R1 = page 0 (RAM), selected by latch 0
+ m.ppu_write(0x0042, 0x99);
+ let blob = m.save_state();
+ let mut n = board(ExtBoard::M165, 32, 128);
+ n.load_state(&blob).expect("same board");
+ assert_eq!(n.save_state(), blob);
+ assert_eq!(
+ n.ppu_read(0x0042),
+ 0x99,
+ "latch 0 = $FE and the RAM restored"
+ );
+ assert!(
+ board(ExtBoard::M197 { sub: 0 }, 32, 128)
+ .load_state(&blob)
+ .is_err()
+ );
+}
diff --git a/crates/rustynes-mappers/src/mmc3_clones.rs b/crates/rustynes-mappers/src/mmc3_clones.rs
index 050578a18..1ea14063d 100644
--- a/crates/rustynes-mappers/src/mmc3_clones.rs
+++ b/crates/rustynes-mappers/src/mmc3_clones.rs
@@ -646,6 +646,27 @@ impl Mapper for Mmc3CloneMapper {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ if matches!(self.board, CloneBoard::M245) {
+ let half = if self.core.chr_mode { 0x1000 } else { 0 };
+ let off = (half ^ (addr as usize & 0x1FFF)) & (self.chr.len() - 1);
+ return self.chr[off];
+ }
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ let slot = (addr as usize) / CHR_BANK_1K;
+ let bank = self.resolve_chr(slot);
+ self.chr[bank * CHR_BANK_1K + (addr as usize & 0x3FF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.core.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/multicart_discrete.rs b/crates/rustynes-mappers/src/multicart_discrete.rs
index 742390fa8..2b6516461 100644
--- a/crates/rustynes-mappers/src/multicart_discrete.rs
+++ b/crates/rustynes-mappers/src/multicart_discrete.rs
@@ -259,6 +259,15 @@ impl Mapper for Multicart15 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -438,6 +447,15 @@ impl Mapper for Multicart61 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -602,6 +620,19 @@ impl Mapper for Multicart62 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -736,6 +767,19 @@ impl Mapper for Multicart200 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = (self.bank as usize) % count;
+ self.chr[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -900,6 +944,15 @@ impl Mapper for Multicart201 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1091,6 +1144,15 @@ impl Mapper for Multicart202 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1266,6 +1328,15 @@ impl Mapper for Multicart203 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1459,6 +1530,15 @@ impl Mapper for Multicart212 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1631,6 +1711,15 @@ impl Mapper for Multicart213 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1799,6 +1888,15 @@ impl Mapper for Multicart214 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1959,6 +2057,19 @@ impl Mapper for Multicart58 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -2092,6 +2203,19 @@ impl Mapper for Multicart60 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -2218,6 +2342,15 @@ impl Mapper for Multicart231 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -2393,6 +2526,19 @@ impl Mapper for Maxi15M234 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank() as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -2563,6 +2709,19 @@ impl Mapper for Multicart225 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -2727,6 +2886,15 @@ impl Mapper for Multicart226 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -2936,6 +3104,15 @@ impl Mapper for Multicart227 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -3113,6 +3290,19 @@ impl Mapper for Multicart229 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -3269,6 +3459,15 @@ impl Mapper for Multicart233 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -3506,7 +3705,18 @@ impl Mapper for DiscreteMapper {
},
DiscreteBoard::M290 => {
if (0x8000..=0xFFFF).contains(&addr) {
- self.prg_16k(self.reg0 as usize, addr)
+ // The latch's `S` bit (A7) picks the window size. `reg0`
+ // is the 16 KiB bank number (`PPPP` doubled, plus `p` in
+ // 16 KiB mode), so the 32 KiB bank is `reg0 >> 1`. v3.2.0:
+ // until then both modes read through the 16 KiB window,
+ // which repeats `$8000`'s half at `$C000` and broke 32 KiB
+ // mode -- the power-on mode, so the reset vector came from
+ // the wrong half (`m290_prg_modes_follow_the_size_bit`).
+ if self.last_addr & 0x80 != 0 {
+ self.prg_16k(self.reg0 as usize, addr)
+ } else {
+ self.prg_32k(self.reg0 as usize >> 1, addr)
+ }
} else {
0
}
@@ -3588,6 +3798,9 @@ impl Mapper for DiscreteMapper {
} else {
Mirroring::Vertical
};
+ // The whole latch, for its `S` bit (read path); already
+ // part of the board's save state.
+ self.last_addr = addr;
}
}
DiscreteBoard::M301 => {
@@ -3619,6 +3832,22 @@ impl Mapper for DiscreteMapper {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ let count = (self.chr.len() / CHR_BANK_8K).max(1);
+ let bank = self.chr_8k_bank() % count;
+ self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -3856,6 +4085,21 @@ impl Mapper for Bmc204 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (CHR_BANK_8K - 1)];
+ }
+ let bank = self.chr8 % self.chr_count_8k();
+ self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -4042,6 +4286,21 @@ impl Mapper for Bmc11160 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (CHR_BANK_8K - 1)];
+ }
+ let bank = self.chr8 % self.chr_count_8k();
+ self.chr[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -4732,6 +4991,43 @@ mod tests {
assert_eq!(m.cpu_read(0xC000), 47 % 32); // high|0x0F = 47.
}
+ /// v3.2.0: `nesdev_wiki/NES_2_0_Mapper_290.xhtml`, `A~[1PPP PMCC Sp.. .CCC]`:
+ /// `S` = 0 maps the 32 KiB bank `PPPP` across all of `$8000-$FFFF` (its two
+ /// 16 KiB halves IN ORDER), `S` = 1 maps 16 KiB bank `PPPPp` at both halves.
+ /// Until v3.2.0 the read path used the 16 KiB window in both modes, so in
+ /// 32 KiB mode `$C000-$FFFF` repeated `$8000`'s half; the latch powers up
+ /// at 0, which is 32 KiB mode, so the reset vector came from the wrong
+ /// half and *Asder 20-in-1* never started (a uniform grey frame).
+ #[test]
+ fn m290_prg_modes_follow_the_size_bit() {
+ let mut m = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ // Power-on: 32 KiB bank 0, halves 0 and 1.
+ assert_eq!(m.cpu_read(0x8000), 0);
+ assert_eq!(
+ m.cpu_read(0xC000),
+ 1,
+ "power-on is 32 KiB mode: $C000 is the upper half"
+ );
+ // S=0, PPPP=1: 32 KiB bank 1 = 16 KiB banks 2 and 3.
+ m.cpu_write(0x8800, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ // S=1, p=1, PPPP=1: 16 KiB bank 3 at both halves.
+ m.cpu_write(0x88C0, 0);
+ assert_eq!(m.cpu_read(0x8000), 3);
+ assert_eq!(m.cpu_read(0xC000), 3);
+ // S=1, p=0: 16 KiB bank 2 at both halves.
+ m.cpu_write(0x8880, 0);
+ assert_eq!(m.cpu_read(0x8000), 2);
+ assert_eq!(m.cpu_read(0xC000), 2);
+ // The mode survives a save-state round trip.
+ m.cpu_write(0x8800, 0);
+ let blob = m.save_state();
+ let mut m2 = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
+ m2.load_state(&blob).unwrap();
+ assert_eq!(m2.cpu_read(0xC000), 3);
+ }
+
#[test]
fn m290_address_decoded_mirror() {
let mut m = new_m290(synth_prg_16k(16), synth_chr_8k(16), Mirroring::Vertical).unwrap();
diff --git a/crates/rustynes-mappers/src/nsf.rs b/crates/rustynes-mappers/src/nsf.rs
index f5c35723c..71e1c2e58 100644
--- a/crates/rustynes-mappers/src/nsf.rs
+++ b/crates/rustynes-mappers/src/nsf.rs
@@ -767,6 +767,11 @@ impl Mapper for NsfMapper {
0
}
+ fn ppu_peek(&self, _addr: u16) -> u8 {
+ // No CHR: NSF files carry no graphics. Reads return open-bus-ish 0.
+ 0
+ }
+
fn ppu_write(&mut self, _addr: u16, _value: u8) {}
fn current_mirroring(&self) -> Mirroring {
diff --git a/crates/rustynes-mappers/src/ntdec.rs b/crates/rustynes-mappers/src/ntdec.rs
index 799eb621c..e41d4e51d 100644
--- a/crates/rustynes-mappers/src/ntdec.rs
+++ b/crates/rustynes-mappers/src/ntdec.rs
@@ -172,6 +172,15 @@ impl Mapper for Ntdec63 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -329,6 +338,19 @@ impl Mapper for Ntdec174 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -469,6 +491,15 @@ impl Mapper for Ntdec2722M40 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -640,6 +671,19 @@ impl Mapper for Ntdec81 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + (addr as usize & 0x1FFF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
@@ -790,6 +834,15 @@ impl Mapper for NtdecAsder112 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.read_chr(addr),
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
@@ -967,6 +1020,23 @@ impl Mapper for NtdecTc112 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (CHR_BANK_8K - 1)];
+ }
+ let slot = (addr as usize) / CHR_BANK_2K;
+ let count = self.chr_count_2k();
+ let bank = self.chr2[slot] % count;
+ self.chr[bank * CHR_BANK_2K + (addr as usize & (CHR_BANK_2K - 1))]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1175,6 +1245,15 @@ impl Mapper for NtdecN625092 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr[addr as usize & (CHR_BANK_8K - 1)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/sachen_8259.rs b/crates/rustynes-mappers/src/sachen_8259.rs
index af87ceb75..81c9e3f61 100644
--- a/crates/rustynes-mappers/src/sachen_8259.rs
+++ b/crates/rustynes-mappers/src/sachen_8259.rs
@@ -182,6 +182,15 @@ impl Mapper for Sachen8259M137 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.read_chr(addr),
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if (0x2000..=0x3EFF).contains(&addr) {
@@ -390,6 +399,23 @@ impl Mapper for Sachen8259 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ if self.chr_is_ram {
+ return self.chr[addr as usize & (self.chr.len() - 1)];
+ }
+ let slot = (addr as usize) / CHR_2K;
+ let count = (self.chr.len() / CHR_2K).max(1);
+ let bank = self.chr_bank(slot) % count;
+ self.chr[bank * CHR_2K + (addr as usize & (CHR_2K - 1))]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mappers/src/sachen_discrete.rs b/crates/rustynes-mappers/src/sachen_discrete.rs
index db1d7cd72..6da5cfada 100644
--- a/crates/rustynes-mappers/src/sachen_discrete.rs
+++ b/crates/rustynes-mappers/src/sachen_discrete.rs
@@ -143,6 +143,19 @@ impl Mapper for Sachen133 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -273,6 +286,19 @@ impl Mapper for Sachen145 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -408,6 +434,19 @@ impl Mapper for Sachen146 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -641,6 +680,15 @@ impl Mapper for Sachen3018M147 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -823,6 +871,15 @@ impl Mapper for Sachen148 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -982,6 +1039,19 @@ impl Mapper for Sachen149 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = (self.chr_bank as usize) % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
@@ -1254,6 +1324,15 @@ impl Mapper for Sachen150 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_rom[self.chr_offset(addr)],
+ 0x2000..=0x3EFF => self.vram[self.resolve_nametable(addr)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -1465,6 +1544,19 @@ impl Mapper for SachenTca01M143 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let count = (self.chr_rom.len() / CHR_BANK_8K).max(1);
+ let bank = 0 % count;
+ self.chr_rom[bank * CHR_BANK_8K + addr as usize]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
if let 0x2000..=0x3EFF = addr {
diff --git a/crates/rustynes-mappers/src/tier.rs b/crates/rustynes-mappers/src/tier.rs
index e46b53547..859de7786 100644
--- a/crates/rustynes-mappers/src/tier.rs
+++ b/crates/rustynes-mappers/src/tier.rs
@@ -132,10 +132,60 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option {
// GTROM (111), promoted from BestEffort once the board matched its
// page (register window, bonus RAM, self-flashing) and a CC0 fixture
// pinned it. None has a redistributable commercial ROM.
- 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 163 | 192 | 195 | 228 | 249 => {
+ //
+ // v3.2.0 adds 157, the Datach Joint ROM System, written from
+ // `INES_Mapper_157` (an LZ93D50 variant in m016) with the barcode
+ // reader in `datach_barcode.rs`. Curated on the D23 record: six
+ // staged dumps boot to their title screens and three accept a
+ // scanned code (`docs/compatibility.md`).
+ 12 | 37 | 45 | 74 | 83 | 91 | 105 | 111 | 153 | 157 | 163 | 192 | 195 | 228 | 249
+ // --- v3.2.0 D23 promotions (`T-CURATED-EVIDENCE`): a local dump counts
+ // as Curated evidence when the record names the title, its hash, the
+ // frames and a committed screenshot (`docs/compatibility.md`, "Curated
+ // evidence from local dumps"). Each boots to a correct picture in the
+ // byte-identity coverage harness:
+ // 47: Super Spike V'Ball's menu, AND (`extended_m47_...`) Nintendo
+ // World Cup's team select, reached through the block register
+ // its page says is writable only with PRG-RAM enabled;
+ // 121: Ultimate Mortal Kombat 3 in a fight, four other titles;
+ // 154: Devil Man (Japan), its opening cutscene;
+ // 191: Mighty Final Fight's cutscene with its CHR-RAM font;
+ // 243: Honey Peach, the one game NESdev lists for the SA-020A (the
+ // Sachen titles GoodNES headers as 243 are SA-015 games, mapper
+ // 150, and are not evidence for this board).
+ // The v2.9.6 rule capped a page of only Disch's notes, or of masks
+ // marked "probably", at BestEffort. The maintainer's ruling for
+ // v3.2.0 (2026-10-09): such a family is Curated on a D23 record when
+ // every behaviour its page leaves unstated was either not needed or
+ // settled by the dump's boot visibly rejecting the alternative (47,
+ // 121, 191; 165 below, whose three choices the screens settled).
+ // Where the model chose an unstated behaviour no boot tested against
+ // an alternative, a correct boot cannot tell a right choice from a
+ // lucky one, and the family stays BestEffort (230, below). ADR 0011's
+ // v3.2.0 amendment.
+ | 47 | 121 | 154 | 191 | 243 => {
Some(MapperTier::Curated)
}
+ // --- v3.2.0: MMC3-derived boards written from their NESdev pages
+ // (`mmc3_boards_ext.rs`), each Curated on a D23 record in
+ // `docs/compatibility.md`: a local dump that boots to its title, named
+ // by title and `Nes::rom_sha256`, pinned by a boot snapshot in
+ // `external_extended.rs` and a committed screenshot. 182 is a
+ // duplicate of 114 (the 114 page) and shares its model; 116 (SOMARI-P,
+ // three simulated chips) is in `m116_somari.rs`. A separate arm
+ // so each release's batch keeps its own rationale beside its ids.
+ #[allow(clippy::match_same_arms)]
+ 114 | 116 | 165 | 182 | 197 | 215 => Some(MapperTier::Curated),
+
+ // --- v3.2.0: families written from a NESdev page with exact register
+ // masks, each also booted on a local dump recorded with its title,
+ // header-excluded hash, frames and a committed screenshot (D23,
+ // `docs/compatibility.md`). A separate arm so each release's batch
+ // reads as one list; the bodies match on purpose.
+ #[allow(clippy::match_same_arms)]
+ 6 | 8 | 17 | 158 | 172 | 173 | 207 | 235 | 252 => Some(MapperTier::Curated),
+
// --- Tier 2 / BestEffort: the 26 reference-ported long-tail families
// that lack a *cleanly-booting* redistributable ROM dump (so they cannot
// be honestly oracle-gated and stay register-decode + save-state
@@ -156,15 +206,22 @@ pub const fn mapper_tier(id: u16, submapper: u8) -> Option {
// database, which is what routes `Devil Man` from its m88 header to 154
// and the Sachen 74LS374N set from m150 to 243. Their dumps are staged
// but not redistributable, so neither can be honestly oracle-gated.
- 29 | 39 | 50 | 81 | 104 | 154 | 174 | 179 | 238 | 243 | 261 | 268 | 286 | 289 | 290
- | 299 | 301 | 303 | 305 | 306 | 312 | 320 | 336 | 348 | 349 | 366 | 513 => {
+ // v3.2.0: both moved to Curated under D23 (above).
+ 29 | 39 | 50 | 81 | 104 | 174 | 179 | 238 | 261 | 268 | 286 | 289 | 290 | 299 | 301
+ | 303 | 305 | 306 | 312 | 320 | 336 | 348 | 349 | 366 | 513
+ // --- v2.9.6 "Roster" BestEffort: pages that give only Disch's notes
+ // (194; 47 and 191 until v3.2.0) or register masks marked "probably"
+ // (121 until v3.2.0).
+ | 194
+ // --- v3.2.0 BestEffort: 230 (reset-switched 22-in-1). Its page is
+ // Disch's notes and does not say which mode the board powers on in
+ // or what a console reset does to its register; the model chooses
+ // both, so its correct boot is not Curated evidence (maintainer's
+ // ruling, 2026-10-09; see the D23 arm above).
+ | 230 => {
Some(MapperTier::BestEffort)
}
- // --- v2.9.6 "Roster" BestEffort: pages that give only Disch's notes
- // (47, 191, 194) or register masks marked "probably" (121).
- 47 | 121 | 191 | 194 => Some(MapperTier::BestEffort),
-
_ => None,
}
}
@@ -192,15 +249,11 @@ mod tests {
/// 154 and 243 landed in v2.3.4 and must be classified, or `parse` supports
/// a mapper the tier table does not know -- the invariant this module exists
- /// to hold.
+ /// to hold. `BestEffort` until v3.2.0, `Curated` since (D23).
#[test]
- fn v234_additions_are_classified_best_effort() {
+ fn v234_additions_are_classified() {
for id in [154u16, 243] {
- assert_eq!(
- mapper_tier(id, 0),
- Some(MapperTier::BestEffort),
- "mapper {id}"
- );
+ assert_eq!(mapper_tier(id, 0), Some(MapperTier::Curated), "mapper {id}");
}
}
@@ -243,6 +296,11 @@ mod tests {
225, 226, 227, 229, 231, 232, 233, 234, 240, 241, 242, 244, 245, 246, 250, 253,
// v2.9.6 "Roster".
12, 37, 45, 74, 83, 91, 105, 111, 153, 163, 192, 195, 228, 249,
+ // v3.2.0: the Datach (new), the D23 promotions, and the new boards in
+ // `mmc3_boards_ext.rs` / `m116_somari.rs`.
+ 157, 47, 121, 154, 191, 243, 114, 116, 165, 182, 197, 215,
+ // v3.2.0: FFE, TXC, Tengen, Taito, Waixing and two multicarts.
+ 6, 8, 17, 158, 172, 173, 207, 235, 252,
];
#[test]
@@ -268,8 +326,9 @@ mod tests {
/// NTDEC/TXC/BMC multicart batch (193/204/221/299).
const BEST_EFFORT_IDS: &[u16] = &[
29, 39, 50, 81, 104, 174, 179, 238, 261, 268, 286, 289, 290, 299, 301, 303, 305, 306, 312,
- 320, 336, 348, 349, 366, 513, // v2.9.6 "Roster":
- 47, 121, 191, 194,
+ 320, 336, 348, 349, 366, 513, // v2.9.6 "Roster" (47, 121, 191 Curated since v3.2.0):
+ 194, // v3.2.0: notes-only with chosen behaviour.
+ 230,
];
#[test]
diff --git a/crates/rustynes-mappers/src/waixing.rs b/crates/rustynes-mappers/src/waixing.rs
index 0d0131111..49a165897 100644
--- a/crates/rustynes-mappers/src/waixing.rs
+++ b/crates/rustynes-mappers/src/waixing.rs
@@ -189,6 +189,15 @@ impl Mapper for Waixing242 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -362,6 +371,15 @@ impl Mapper for WaixingFs304M162 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -559,6 +577,15 @@ impl Mapper for Waixing178 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => self.chr_ram[addr as usize],
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.current_mirroring())],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
@@ -782,6 +809,26 @@ impl Mapper for Waixing253 {
}
}
+ fn ppu_peek(&self, addr: u16) -> u8 {
+ let addr = addr & 0x3FFF;
+ match addr {
+ 0x0000..=0x1FFF => {
+ let slot = (addr as usize) / CHR_BANK_1K;
+ let lo = self.chr_low[slot];
+ if (lo == 4 || lo == 5) && !self.force_chr_rom {
+ let page = (lo as usize & 0x01) * CHR_BANK_1K;
+ return self.chr_ram
+ [(page + (addr as usize & 0x3FF)) & (self.chr_ram.len() - 1)];
+ }
+ let page =
+ (lo as usize | ((self.chr_high[slot] as usize) << 8)) % self.chr_count_1k;
+ self.chr[page * CHR_BANK_1K + (addr as usize & 0x3FF)]
+ }
+ 0x2000..=0x3EFF => self.vram[nametable_offset(addr, self.mirroring)],
+ _ => 0,
+ }
+ }
+
fn ppu_write(&mut self, addr: u16, value: u8) {
let addr = addr & 0x3FFF;
match addr {
diff --git a/crates/rustynes-mobile/Cargo.toml b/crates/rustynes-mobile/Cargo.toml
index 0efa62ea2..e302c48ee 100644
--- a/crates/rustynes-mobile/Cargo.toml
+++ b/crates/rustynes-mobile/Cargo.toml
@@ -71,7 +71,8 @@ uniffi = { version = "0.32", features = ["cli"] }
# v1.8.5 — accept still-compressed `.zip` ROMs (extract the first NES entry), the
# same convenience the desktop has. `deflate` is the only codec real NES zips use;
# default features off keeps bzip2/zstd/aes out of the mobile binary.
-zip = { version = "8", default-features = false, features = ["deflate"] }
+zip = { version = "9", default-features = false, features = ["deflate"] }
+getrandom = "0.2"
[dev-dependencies]
# v1.8.7 Phase B — the room-code loopback test stands up an in-process WebSocket
diff --git a/crates/rustynes-mobile/src/lib.rs b/crates/rustynes-mobile/src/lib.rs
index abf1b144d..ce43373b2 100644
--- a/crates/rustynes-mobile/src/lib.rs
+++ b/crates/rustynes-mobile/src/lib.rs
@@ -1722,9 +1722,12 @@ impl NesController {
/// v2.9.7 — the FDS disk image as it stands, including anything the game
/// has written to it, in the headerless `.fds` layout. The host writes it
- /// to its own save file and, on the next launch, loads THAT file as the
- /// ROM, which is how a disk game's progress persists (FDS games save to
- /// the disk; there is no battery RAM). Empty for a cartridge or NSF.
+ /// to its own save file (keyed by [`Self::rom_identity`]) and, on the next
+ /// launch, builds from the pristine image and passes the file to
+ /// [`Self::boot_saved_disk`], which is how a disk game's progress persists
+ /// (FDS games save to the disk; there is no battery RAM). Loading the saved
+ /// file AS the ROM instead would change the game's identity (v3.2.0).
+ /// Empty for a cartridge or NSF.
pub fn disk_image_bytes(&self) -> Vec {
self.lock().nes.disk_image_bytes()
}
@@ -1741,6 +1744,74 @@ impl NesController {
self.lock().nes.clear_disk_dirty();
}
+ /// v3.2.0 — boot the host's SAVED copy of the loaded FDS disk (the
+ /// [`Self::disk_image_bytes`] it wrote last session) in place of the
+ /// pristine image, KEEPING the pristine image's identity.
+ ///
+ /// This is the desktop's `.fds.sav` design (`boot_saved_disk` in the
+ /// frontend, NF-17). A host that instead loaded the saved file as the ROM
+ /// would change [`Self::rom_identity`] at the game's first disk save,
+ /// because the core hashes the bytes it boots: every store keyed on it --
+ /// save states, cheats, RA progress, overrides -- would be orphaned, and
+ /// the save file itself (keyed by identity) would never be found again.
+ ///
+ /// Call it right after building the controller from the pristine image,
+ /// with the file keyed by that controller's `rom_identity()`. The BIOS
+ /// and sample rate are the controller's own.
+ ///
+ /// # Errors
+ /// [`MobileError::RomLoad`] if the loaded console is not an FDS disk, or
+ /// the saved image does not parse; the pristine disk stays loaded, so the
+ /// host can log it and play on (the bad file is not overwritten until
+ /// the game writes to the disk again).
+ pub fn boot_saved_disk(&self, saved: Vec) -> Result<(), MobileError> {
+ check_rom_size(&saved)?;
+ let not_disk = || MobileError::RomLoad {
+ reason: "the loaded game is not an FDS disk".into(),
+ };
+ // What the parse needs, read under the lock; the parse runs with it
+ // released so the render and audio paths are not held up by it.
+ let (bios, pristine, sample_rate) = {
+ let g = self.lock();
+ if g.nes.disk_side_count() == 0 {
+ return Err(not_disk());
+ }
+ let bios = g.fds_bios.clone().ok_or_else(not_disk)?;
+ (bios, *g.nes.rom_sha256(), g.sample_rate)
+ };
+ let mut nes = Nes::from_disk_with_sample_rate(&saved, &bios, sample_rate).map_err(|e| {
+ MobileError::RomLoad {
+ reason: format!("saved FDS disk: {e}"),
+ }
+ })?;
+ nes.set_rom_identity(pristine);
+ let mut g = self.lock();
+ // Replacing the console mid-movie or mid-session would desynchronise
+ // it, as a Reset or a state load would, so the same NF-18 rule applies
+ // (`CodeRabbit` on #604 and #605). Both hosts call this right after
+ // building the controller, before either can exist; this guards the
+ // public API, not a path the apps take today.
+ if let Some(why) = timeline_refusal(&g) {
+ return Err(MobileError::RomLoad {
+ reason: format!("saved disk boot refused: {why}"),
+ });
+ }
+ // Install it only over the disk it was read for: a different game
+ // loaded while the lock was released must not be replaced.
+ if *g.nes.rom_sha256() != pristine {
+ return Err(MobileError::RomLoad {
+ reason: "a different game was loaded meanwhile".into(),
+ });
+ }
+ g.nes = nes;
+ // A whole new console: nothing a contained panic or a coin pulse left
+ // behind applies to it (as after a state load).
+ g.frozen = false;
+ g.coin_frames = 0;
+ drop(g);
+ Ok(())
+ }
+
// --- NSF music files (v2.9.7 "Tandem", plan item 6) -----------------
/// v2.9.7 — the number of songs in the loaded NSF; 0 for a cartridge or
@@ -2946,14 +3017,18 @@ fn decompress_rom(bytes: Vec) -> Vec {
let extracted = (|| {
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).ok()?;
let idx = (0..archive.len()).find(|&i| {
+ // zip 9: an entry whose name decodes as neither UTF-8 nor CP437
+ // is skipped, the same as an unreadable entry.
archive.by_index(i).is_ok_and(|e| {
- std::path::Path::new(e.name())
- .extension()
- .is_some_and(|ext| {
- ["nes", "fds", "unf", "unif", "nsf", "nsfe"]
- .iter()
- .any(|k| ext.eq_ignore_ascii_case(k))
- })
+ e.name().is_ok_and(|n| {
+ std::path::Path::new(n.as_ref())
+ .extension()
+ .is_some_and(|ext| {
+ ["nes", "fds", "unf", "unif", "nsf", "nsfe"]
+ .iter()
+ .any(|k| ext.eq_ignore_ascii_case(k))
+ })
+ })
})
})?;
let e = archive.by_index(idx).ok()?;
@@ -2990,10 +3065,12 @@ fn read_bk2_members(bytes: &[u8]) -> Result<(String, String), MobileError> {
let idx = (0..archive.len())
.find(|&i| {
archive.by_index(i).is_ok_and(|e| {
- std::path::Path::new(e.name())
- .file_name()
- .and_then(|n| n.to_str())
- .is_some_and(|n| n.eq_ignore_ascii_case(wanted))
+ e.name().is_ok_and(|name| {
+ std::path::Path::new(name.as_ref())
+ .file_name()
+ .and_then(|n| n.to_str())
+ .is_some_and(|n| n.eq_ignore_ascii_case(wanted))
+ })
})
})
.ok_or_else(|| err(format!(".bk2 is missing '{wanted}'")))?;
@@ -3023,9 +3100,7 @@ fn composite_hd_locked(g: &mut Inner) -> Vec {
return Vec::new();
}
// Snapshot the per-pixel tile source, the CHR (0x0000..0x2000), and the frame.
- let hd_tiles = g.nes.hd_tile_source().to_vec();
- let framebuffer = g.nes.framebuffer().to_vec();
- let mut chr = vec![0u8; 0x2000];
+ let mut chr = [0u8; 0x2000];
for (addr, slot) in (0u16..0x2000).zip(chr.iter_mut()) {
*slot = g.nes.peek_ppu(addr);
}
@@ -3049,7 +3124,9 @@ fn composite_hd_locked(g: &mut Inner) -> Vec {
};
#[cfg(test)]
injected_frame_fault();
- comp.composite(&framebuffer, &hd_tiles, &watched, |addr| {
+ let hd_tiles = g.nes.hd_tile_source();
+ let framebuffer = g.nes.framebuffer();
+ comp.composite(framebuffer, hd_tiles, &watched, |addr| {
chr.get((addr & 0x1FFF) as usize).copied().unwrap_or(0)
})
.to_vec()
@@ -3158,6 +3235,13 @@ fn nat_phase_detail(phase: &NatPhase) -> String {
/// deterministically when the session promotes to `InGame`.
fn nondeterministic_seed() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
+ // OS randomness first (v3.2.0): this seeds the room code and, through the
+ // session RNG, the STUN / TURN transaction ids, which should not be
+ // guessable. The time-and-address mix below is the fallback only.
+ let mut buf = [0u8; 8];
+ if getrandom::getrandom(&mut buf).is_ok() {
+ return u64::from_le_bytes(buf);
+ }
// Truncating the 128-bit nanosecond count to 64 bits is intentional — this is
// a PRNG seed, not a timestamp, so the discarded high bits don't matter.
#[allow(clippy::cast_possible_truncation)]
@@ -4216,6 +4300,74 @@ mod tests {
assert_eq!(ctrl.nsf_song_count(), 0, "a disk is not an NSF");
}
+ /// v3.2.0: a saved disk boots under the PRISTINE disk's identity, so the
+ /// stores keyed on it (and the save file itself) survive the game's first
+ /// disk write; a cartridge refuses it and keeps running.
+ #[test]
+ fn a_saved_disk_boots_under_the_pristine_identity() {
+ let ctrl = NesController::new_with_fds_bios(
+ synthetic_fds_disk(1),
+ Some(synthetic_fds_bios()),
+ DEFAULT_SAMPLE_RATE,
+ )
+ .expect("disk loads");
+ let pristine = ctrl.rom_identity();
+ let mut saved = ctrl.disk_image_bytes();
+ let last = saved.len() - 1;
+ saved[last] ^= 0x5A; // stands in for the game's own write
+ assert_ne!(
+ rom_identity_of_file(saved.clone()).unwrap(),
+ pristine,
+ "fixture: the saved bytes hash differently"
+ );
+
+ ctrl.boot_saved_disk(saved.clone())
+ .expect("the saved disk boots");
+ assert_eq!(
+ ctrl.rom_identity(),
+ pristine,
+ "identity is the pristine disk's"
+ );
+ assert_eq!(
+ ctrl.disk_image_bytes(),
+ saved,
+ "and the saved bytes are loaded"
+ );
+ ctrl.step_frame();
+
+ let cart = NesController::new(tiny_nrom(), DEFAULT_SAMPLE_RATE).expect("load");
+ let before = cart.rom_identity();
+ assert!(
+ cart.boot_saved_disk(saved).is_err(),
+ "a cartridge refuses it"
+ );
+ assert_eq!(cart.rom_identity(), before, "and keeps running");
+ }
+
+ /// While a movie owns the timeline the saved disk is refused, as a Reset
+ /// or a state load is (NF-18): replacing the console would desynchronise
+ /// the recording (`CodeRabbit` on #604 and #605).
+ #[test]
+ fn a_saved_disk_is_refused_while_a_movie_records() {
+ let ctrl = NesController::new_with_fds_bios(
+ synthetic_fds_disk(1),
+ Some(synthetic_fds_bios()),
+ DEFAULT_SAMPLE_RATE,
+ )
+ .expect("disk loads");
+ let mut saved = ctrl.disk_image_bytes();
+ let last = saved.len() - 1;
+ saved[last] ^= 0x5A;
+ ctrl.movie_record_from_here();
+ assert!(ctrl.movie_is_recording());
+ let before = ctrl.disk_image_bytes();
+ assert!(
+ ctrl.boot_saved_disk(saved).is_err(),
+ "refused under a movie"
+ );
+ assert_eq!(ctrl.disk_image_bytes(), before, "the running disk is kept");
+ }
+
/// Plan item 6: the BIOS set on a controller persists across `load_rom`,
/// so a host that stored it once can swap disks without re-supplying it.
#[test]
diff --git a/crates/rustynes-netplay/src/connection.rs b/crates/rustynes-netplay/src/connection.rs
index 24dba4602..dbf4da74a 100644
--- a/crates/rustynes-netplay/src/connection.rs
+++ b/crates/rustynes-netplay/src/connection.rs
@@ -325,6 +325,13 @@ impl Transport for UdpTransport {
}
}
+ // No source filter here, deliberately (v3.2.0 review). A filter on
+ // `self.remote` was tried and removed: it dropped every datagram after a
+ // NAT rebinding (the peer's public port changes mid-session) and after an
+ // IPv4 peer reappears as its IPv4-mapped IPv6 form, ending the session,
+ // while protecting nothing -- a UDP source is trivially spoofed, and the
+ // protocol has no authentication for it to stand in for. Adoption
+ // (`set_remote`) is still first-come, so a stray `Sync` cannot re-point it.
fn poll(&mut self) -> Vec {
self.poll_with_source()
.into_iter()
@@ -800,7 +807,8 @@ impl NetplayConnection {
NetMessage::Input { .. }
| NetMessage::InputAck { .. }
| NetMessage::Checksum { .. }
- | NetMessage::Roster { .. } => {}
+ | NetMessage::Roster { .. }
+ | NetMessage::InputBatch { .. } => {}
}
}
diff --git a/crates/rustynes-netplay/src/mesh_net.rs b/crates/rustynes-netplay/src/mesh_net.rs
index 1e07ea21d..457ae43c6 100644
--- a/crates/rustynes-netplay/src/mesh_net.rs
+++ b/crates/rustynes-netplay/src/mesh_net.rs
@@ -403,11 +403,16 @@ impl MeshHost {
self.last_roster_sent = Some(now);
self.roster_resends -= 1;
}
- // After the first broadcast, build + return the host's transport.
- // (We still want the joiners to have received it; the resends above
- // ride out loss, but the host can proceed immediately — its mesh
- // transport simply starts sending Inputs, which also doubles as
- // liveness for the joiners.)
+ // v3.2.0: hand off only after the LAST resend. The resends exist to
+ // ride out a lost roster, and they are sent from this pump; once
+ // the host has its transport nothing pumps this handshake again.
+ // Until v3.2.0 it handed off after the first broadcast, so the
+ // other four were never sent and one lost datagram stranded a
+ // joiner. Cost: the host starts about
+ // (ROSTER_RESENDS - 1) x ROSTER_RESEND_INTERVAL = 200 ms later.
+ if self.roster_resends > 0 {
+ return Ok(None);
+ }
let socket = self.socket.take().expect("host socket present to hand off");
let peers: Vec = self.joiners.keys().copied().collect();
// We never reach here without at least the resend above; map an I/O
@@ -819,8 +824,13 @@ mod tests {
let listen = host.local_addr().unwrap();
let mut j = MeshJoiner::connect(loopback(), listen, 1, id).unwrap();
let mut done = false;
+ let mut j_done = false;
for _ in 0..500 {
- let _ = j.pump();
+ // The joiner hands off once it has the roster; stop pumping it
+ // then, as a real caller would (the host keeps resending).
+ if !j_done && let Ok(Some(_)) = j.pump() {
+ j_done = true;
+ }
if host.pump().expect("host pump").is_some() {
done = true;
break;
diff --git a/crates/rustynes-netplay/src/message.rs b/crates/rustynes-netplay/src/message.rs
index 0357043b4..2ac0db651 100644
--- a/crates/rustynes-netplay/src/message.rs
+++ b/crates/rustynes-netplay/src/message.rs
@@ -176,9 +176,13 @@ impl SessionIdentity {
Err(why) => SyncVerdict::Refuse(why),
}
} else if NetMessage::OLDER_SYNC_MAGICS.contains(&magic) {
- // Protocol 6 carries its epoch (same layout as ours), so the reason
- // can name it; protocols 4 and 5 have none.
- let theirs = (magic == NetMessage::PROTOCOL_6_SYNC_MAGIC).then_some(peer.epoch);
+ // Protocols 6 and 7 carry their epoch (same layout as ours), so the
+ // reason can name it; protocols 4 and 5 have none.
+ let theirs = matches!(
+ magic,
+ NetMessage::PROTOCOL_6_SYNC_MAGIC | NetMessage::PROTOCOL_7_SYNC_MAGIC
+ )
+ .then_some(peer.epoch);
SyncVerdict::Refuse(IdentityMismatch::Emulator {
ours: self.epoch,
theirs,
@@ -229,8 +233,16 @@ impl SessionIdentity {
/// is one of [`NetMessage::OLDER_SYNC_MAGICS`] and it is refused as another
/// emulator version, naming its epoch (protocol 6 carries it).
///
+/// `8` (v3.2.0): [`NetMessage::InputBatch`] (tag 6) -- a run of consecutive
+/// inputs for one player in one datagram, which the retransmit window now
+/// sends instead of one `Input` per frame. The `Sync` layout is unchanged
+/// (72 bytes), under the magic `"RNE8"`; a v3.1.x peer's `"RNE7"` joins
+/// [`NetMessage::OLDER_SYNC_MAGICS`] and is refused as another emulator
+/// version, naming its epoch, because it could not decode a batch: its
+/// [`from_bytes`] drops tag 6, so every retransmission would be lost.
+///
/// [`from_bytes`]: NetMessage::from_bytes
-pub const PROTOCOL_VERSION: u32 = 7;
+pub const PROTOCOL_VERSION: u32 = 8;
/// Messages exchanged between two peers.
///
@@ -260,6 +272,25 @@ pub enum NetMessage {
input: u8,
},
+ /// v3.2.0 (protocol 8): consecutive confirmed inputs for one player, the
+ /// first at `start_frame`, in one datagram. The retransmit window
+ /// ([`crate::RollbackSession`]) sends its resends this way rather than as
+ /// one [`Self::Input`] per frame. Each element is folded in exactly as an
+ /// `Input` for frame `start_frame + i` would be.
+ ///
+ /// Wire: tag, `player`, `start_frame` (LE `u32`), a count byte, then
+ /// `count` input bytes. At most [`Self::MAX_INPUT_BATCH`] inputs; a count
+ /// of 0, a truncated payload or trailing bytes decode to `None`.
+ InputBatch {
+ /// The 0-based player index the inputs belong to (as for `Input`).
+ player: u8,
+ /// The frame of `inputs[0]`.
+ start_frame: u32,
+ /// The inputs for `start_frame`, `start_frame + 1`, ... (1 to
+ /// [`Self::MAX_INPUT_BATCH`] of them).
+ inputs: Vec,
+ },
+
/// Acknowledges receipt of the remote's input up to `frame`. Lets the
/// sender stop retransmitting already-confirmed inputs (Stage 2 uses
/// this for the sliding-window retransmit cutoff).
@@ -336,7 +367,7 @@ impl NetMessage {
/// consider the session synced while the v5 side waited for a reply it
/// refuses. Every version compares the magic, so a changed one is
/// rejected on both sides.
- pub const SYNC_MAGIC: u32 = 0x524E_4537; // "RNE7"
+ pub const SYNC_MAGIC: u32 = 0x524E_4538; // "RNE8"
/// `RustyNES`'s own earlier `Sync` magics: protocol 4 (`"RNES"`, v2.5.x to
/// v2.9.7), protocol 5 (`"RNE5"`, v2.9.8 and v2.9.9) and protocol 6
@@ -344,10 +375,11 @@ impl NetMessage {
/// is refused with a reason ([`SessionIdentity::check_sync`]). Code that
/// needs one protocol names its constant below rather than an index, so
/// adding a protocol to this list cannot silently re-point a check.
- pub const OLDER_SYNC_MAGICS: [u32; 3] = [
+ pub const OLDER_SYNC_MAGICS: [u32; 4] = [
Self::PROTOCOL_4_SYNC_MAGIC,
Self::PROTOCOL_5_SYNC_MAGIC,
Self::PROTOCOL_6_SYNC_MAGIC,
+ Self::PROTOCOL_7_SYNC_MAGIC,
];
/// Protocol 4's `Sync` magic, `"RNES"` (v2.5.x to v2.9.7): a 36-byte
/// payload with no config hash and no epoch.
@@ -358,6 +390,13 @@ impl NetMessage {
/// Protocol 6's `Sync` magic, `"RNE6"` (v3.0.0 and v3.0.1): today's layout,
/// epoch included, so a refusal can name the peer's epoch.
pub const PROTOCOL_6_SYNC_MAGIC: u32 = 0x524E_4536;
+ /// Protocol 7's `Sync` magic, `"RNE7"` (v3.1.x): today's layout, epoch
+ /// included. Refused because a protocol 7 peer cannot decode
+ /// [`Self::InputBatch`].
+ pub const PROTOCOL_7_SYNC_MAGIC: u32 = 0x524E_4537;
+ /// The most inputs one [`Self::InputBatch`] carries: what its count byte
+ /// can express. The session's retransmit window is far smaller.
+ pub const MAX_INPUT_BATCH: usize = u8::MAX as usize;
// Tag bytes for the hand-rolled encoding.
const TAG_INPUT: u8 = 0;
@@ -366,6 +405,7 @@ impl NetMessage {
const TAG_CHECKSUM: u8 = 3;
const TAG_QUALITY: u8 = 4;
const TAG_ROSTER: u8 = 5;
+ const TAG_INPUT_BATCH: u8 = 6;
// IP-family tags inside a `Roster` entry's address encoding.
const IP_V4: u8 = 4;
@@ -396,6 +436,23 @@ impl NetMessage {
out.extend_from_slice(&frame.to_le_bytes());
out.push(input);
}
+ Self::InputBatch {
+ player,
+ start_frame,
+ ref inputs,
+ } => {
+ // The count byte and the payload must agree, so a batch
+ // longer than the count can express is cut to
+ // MAX_INPUT_BATCH rather than written whole under a wrapped
+ // or saturated count (the receiver would mis-frame it). The
+ // session never builds one that long.
+ let inputs = &inputs[..inputs.len().min(Self::MAX_INPUT_BATCH)];
+ out.push(Self::TAG_INPUT_BATCH);
+ out.push(player);
+ out.extend_from_slice(&start_frame.to_le_bytes());
+ out.push(u8::try_from(inputs.len()).unwrap_or(u8::MAX));
+ out.extend_from_slice(inputs);
+ }
Self::InputAck { frame } => {
out.push(Self::TAG_INPUT_ACK);
out.extend_from_slice(&frame.to_le_bytes());
@@ -477,6 +534,25 @@ impl NetMessage {
}
}
+ /// The body of an [`Self::InputBatch`] (after its tag): player, LE start
+ /// frame, a count byte, then exactly `count` inputs, at least one. An
+ /// empty batch carries nothing, and a short or long payload is not this
+ /// encoding; both are `None`.
+ fn decode_input_batch(rest: &[u8]) -> Option {
+ let player = *rest.first()?;
+ let start_frame = u32::from_le_bytes(rest.get(1..5)?.try_into().ok()?);
+ let count = usize::from(*rest.get(5)?);
+ let inputs = rest.get(6..)?;
+ if count == 0 || inputs.len() != count {
+ return None;
+ }
+ Some(Self::InputBatch {
+ player,
+ start_frame,
+ inputs: inputs.to_vec(),
+ })
+ }
+
/// Parse a buffer produced by [`Self::to_bytes`]. Returns `None` on a
/// malformed / truncated / unknown-tag buffer (the UDP transport drops
/// such datagrams rather than panicking).
@@ -498,13 +574,14 @@ impl NetMessage {
let frame = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?);
Some(Self::InputAck { frame })
}
+ Self::TAG_INPUT_BATCH => Self::decode_input_batch(rest),
Self::TAG_SYNC => {
let magic = u32::from_le_bytes(rest.get(0..4)?.try_into().ok()?);
match rest.len() {
- // Protocols 6 and 7: magic + epoch + two hashes. Exactly
+ // Protocols 6, 7 and 8: magic + epoch + two hashes. Exactly
// that length: a longer payload is a later protocol's, not
// this one's with junk on the end. Which protocol it is
- // (`"RNE7"` ours, `"RNE6"` v3.0.x) is `check_sync`'s call.
+ // (`"RNE8"` ours, `"RNE7"` v3.1.x, `"RNE6"` v3.0.x) is `check_sync`'s call.
72 => {
let epoch = u32::from_le_bytes(rest.get(4..8)?.try_into().ok()?);
let rom_hash: [u8; 32] = rest.get(8..40)?.try_into().ok()?;
@@ -759,6 +836,78 @@ mod tests {
assert_ne!(NetMessage::SYNC_MAGIC, NetMessage::PROTOCOL_6_SYNC_MAGIC);
}
+ /// v3.2.0 (protocol 8) — a v3.1.x peer's `"RNE7"` `Sync` has our layout,
+ /// and could even carry our epoch and hashes, but it cannot decode an
+ /// `InputBatch`, so it is refused as another emulator version, naming
+ /// the epoch its layout carries.
+ #[test]
+ fn a_protocol_7_peer_is_refused_as_another_version_naming_its_epoch() {
+ let ours = SessionIdentity::new([7u8; 32], [9u8; 32]);
+ // The same identity as ours, but under protocol 7's magic.
+ let bytes = NetMessage::Sync {
+ magic: NetMessage::PROTOCOL_7_SYNC_MAGIC,
+ identity: ours,
+ }
+ .to_bytes();
+ let Some(NetMessage::Sync { magic, identity }) = NetMessage::from_bytes(&bytes) else {
+ panic!("a protocol-7 Sync decodes");
+ };
+ assert_eq!(
+ ours.check_sync(magic, &identity),
+ SyncVerdict::Refuse(IdentityMismatch::Emulator {
+ ours: ours.epoch,
+ theirs: Some(ours.epoch),
+ })
+ );
+ assert_eq!(NetMessage::SYNC_MAGIC, 0x524E_4538, "\"RNE8\"");
+ assert_eq!(PROTOCOL_VERSION, 8);
+ }
+
+ /// v3.2.0 — the `InputBatch` wire form, byte for byte, and its bounds.
+ #[test]
+ fn input_batch_encodes_decodes_and_rejects_malformed_payloads() {
+ let msg = NetMessage::InputBatch {
+ player: 1,
+ start_frame: 0x0102_0304,
+ inputs: vec![0xAA, 0x00, 0x55],
+ };
+ let bytes = msg.to_bytes();
+ assert_eq!(
+ bytes,
+ [6, 1, 0x04, 0x03, 0x02, 0x01, 3, 0xAA, 0x00, 0x55],
+ "tag, player, LE frame, count, inputs"
+ );
+ assert_eq!(NetMessage::from_bytes(&bytes), Some(msg));
+
+ // Truncated anywhere, padded, or empty: refused.
+ for cut in 1..bytes.len() {
+ assert_eq!(NetMessage::from_bytes(&bytes[..cut]), None, "cut at {cut}");
+ }
+ let mut long = bytes;
+ long.push(0);
+ assert_eq!(NetMessage::from_bytes(&long), None, "trailing byte");
+ assert_eq!(
+ NetMessage::from_bytes(&[6, 0, 0, 0, 0, 0, 0]),
+ None,
+ "an empty batch"
+ );
+
+ // The largest batch round-trips; a longer one is cut to the cap so
+ // the count byte and the payload still agree.
+ let full = NetMessage::InputBatch {
+ player: 0,
+ start_frame: 9,
+ inputs: vec![7; NetMessage::MAX_INPUT_BATCH],
+ };
+ roundtrip(&full);
+ let over = NetMessage::InputBatch {
+ player: 0,
+ start_frame: 9,
+ inputs: vec![7; NetMessage::MAX_INPUT_BATCH + 10],
+ };
+ assert_eq!(NetMessage::from_bytes(&over.to_bytes()), Some(full));
+ }
+
#[test]
fn roster_roundtrips_v4_and_v6() {
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
diff --git a/crates/rustynes-netplay/src/relay.rs b/crates/rustynes-netplay/src/relay.rs
index a55748381..cbd8d8402 100644
--- a/crates/rustynes-netplay/src/relay.rs
+++ b/crates/rustynes-netplay/src/relay.rs
@@ -340,6 +340,12 @@ impl TurnClient {
// ── internal encoders ───────────────────────────────────────────────────
fn next_tx(&mut self) -> TransactionId {
+ // From the seeded PRNG, so a test's explicit seed fixes the ids. A real
+ // session's seed is OS randomness: the mobile bridge seeds its
+ // `NatConnect` from `getrandom`, and that RNG seeds this one. (v3.2.0
+ // review: drawing from `getrandom` here instead ignored the seed and
+ // made the netplay crate depend on it unconditionally, breaking the
+ // wasm build.)
let mut tx = [0u8; 12];
for chunk in tx.chunks_mut(4) {
let r = self.rng.next_u64().to_le_bytes();
diff --git a/crates/rustynes-netplay/src/session.rs b/crates/rustynes-netplay/src/session.rs
index 0a0a049c2..5896e43db 100644
--- a/crates/rustynes-netplay/src/session.rs
+++ b/crates/rustynes-netplay/src/session.rs
@@ -67,6 +67,9 @@ pub const MAX_PLAYERS: usize = 4;
/// normal conditions, with acks flowing, far fewer (the in-flight latency
/// window) are resent.
const INPUT_RESEND_WINDOW: u32 = 64;
+// The whole retransmit window (`INPUT_RESEND_WINDOW + 1` frames) fits one
+// protocol-8 `InputBatch`, whose count byte caps it.
+const _: () = assert!((INPUT_RESEND_WINDOW as usize) < NetMessage::MAX_INPUT_BATCH);
/// How far past this peer's [`current_frame`](RollbackSession::current_frame)
/// a remote `Input.frame` / `Checksum.frame` may sit before the message is
@@ -264,19 +267,13 @@ pub struct RollbackSession {
/// production build would prune; see the Stage 2/3 handoff).
history: Vec,
- /// Save-state ring: `snapshots[f]` is the emulator state *before* frame
- /// `f` was run (so restoring it and running forward reproduces frame f).
- /// Indexed by frame; only the trailing `max_rollback_frames + 1` entries
- /// are guaranteed present (older are `None` after pruning).
- snapshots: Vec